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 ADDED
@@ -0,0 +1,22 @@
1
+ """Public API for HandoffKit."""
2
+
3
+ from handoffkit.agent import Agent
4
+ from handoffkit.errors import HandoffValidationError
5
+ from handoffkit.handoff import HandoffState
6
+ from handoffkit.protocol import HandoffProtocol
7
+ from handoffkit.runner import Team, TeamRunResult
8
+ from handoffkit.tool import Tool, tool
9
+
10
+ __version__ = "0.2.0"
11
+
12
+ __all__ = [
13
+ "Agent",
14
+ "HandoffValidationError",
15
+ "HandoffProtocol",
16
+ "HandoffState",
17
+ "Team",
18
+ "TeamRunResult",
19
+ "Tool",
20
+ "__version__",
21
+ "tool",
22
+ ]
handoffkit/agent.py ADDED
@@ -0,0 +1,91 @@
1
+ """Agent abstraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+ from typing import Any
7
+
8
+ from handoffkit.handoff import HandoffState
9
+ from handoffkit.memory import AgentMemory
10
+ from handoffkit.providers import BaseProvider, EchoProvider
11
+ from handoffkit.tool import Tool, ensure_tool
12
+
13
+
14
+ class Agent:
15
+ """AI agent with a role, provider, memory, and executable tools."""
16
+
17
+ def __init__(
18
+ self,
19
+ name: str,
20
+ role: str,
21
+ *,
22
+ model: str = "echo",
23
+ tools: Sequence[Tool | Callable[..., Any]] | None = None,
24
+ provider: BaseProvider | None = None,
25
+ memory: AgentMemory | None = None,
26
+ ) -> None:
27
+ if not name.strip():
28
+ raise ValueError("Agent name cannot be empty.")
29
+ if not role.strip():
30
+ raise ValueError("Agent role cannot be empty.")
31
+ self.name = name
32
+ self.role = role
33
+ self.model = model
34
+ self.provider = provider or EchoProvider(model=model)
35
+ self.memory = memory or AgentMemory()
36
+ self.tools: list[Tool] = []
37
+ for item in tools or []:
38
+ self.add_tool(item)
39
+
40
+ def add_tool(self, item: Tool | Callable[..., Any]) -> Tool:
41
+ """Add a tool or callable to this agent."""
42
+ wrapped = ensure_tool(item)
43
+ self.tools.append(wrapped)
44
+ return wrapped
45
+
46
+ def describe(self) -> dict[str, Any]:
47
+ """Return a structured description of the agent."""
48
+ return {
49
+ "name": self.name,
50
+ "role": self.role,
51
+ "model": self.model,
52
+ "provider": self.provider.__class__.__name__,
53
+ "tools": [tool.to_dict() for tool in self.tools],
54
+ }
55
+
56
+ def run_tool(self, name: str, **kwargs: Any) -> Any:
57
+ """Run one registered tool by name."""
58
+ for item in self.tools:
59
+ if item.name == name:
60
+ return item.run(**kwargs)
61
+ raise ValueError(f"Tool not found for agent {self.name!r}: {name}")
62
+
63
+ def run(self, task: str, *, handoff_state: HandoffState | None = None, **kwargs: Any) -> str:
64
+ """Run the agent on a task and return provider output."""
65
+ prompt = self._build_prompt(task, handoff_state=handoff_state)
66
+ self.memory.add("user", task, agent=self.name)
67
+ output = self.provider.generate(prompt, **kwargs)
68
+ self.memory.add("assistant", output, agent=self.name)
69
+ return output
70
+
71
+ def _build_prompt(self, task: str, *, handoff_state: HandoffState | None) -> str:
72
+ tool_lines = "\n".join(
73
+ f"- {tool.name}: {tool.description or 'No description'}"
74
+ for tool in self.tools
75
+ )
76
+ if not tool_lines:
77
+ tool_lines = "- No tools registered"
78
+ memory_text = self.memory.to_text(count=4) or "No prior memory."
79
+ handoff_text = handoff_state.to_json(indent=None) if handoff_state else "No handoff state."
80
+ return (
81
+ f"Agent: {self.name}\n"
82
+ f"Role: {self.role}\n"
83
+ f"Task: {task}\n\n"
84
+ f"Tools:\n{tool_lines}\n\n"
85
+ f"Recent memory:\n{memory_text}\n\n"
86
+ f"Handoff state:\n{handoff_text}\n\n"
87
+ "Respond with useful progress, decisions, errors if any, and next steps."
88
+ )
89
+
90
+ def __repr__(self) -> str:
91
+ return f"Agent(name={self.name!r}, role={self.role!r})"
handoffkit/cli.py ADDED
@@ -0,0 +1,49 @@
1
+ """Command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from handoffkit import __version__
8
+ from handoffkit.agent import Agent
9
+ from handoffkit.protocol import HandoffProtocol
10
+ from handoffkit.runner import Team
11
+
12
+
13
+ def run_demo() -> str:
14
+ """Run a local demo using EchoProvider."""
15
+ architect = Agent("Architect", "Analyze projects and create technical plans")
16
+ coder = Agent("Coder", "Write code based on received handoff state")
17
+ tester = Agent("Tester", "Test implementation and report errors")
18
+ team = Team(
19
+ agents=[architect, coder, tester],
20
+ protocol=HandoffProtocol(mode="hybrid_state"),
21
+ )
22
+ result = team.run("Create a small Python CLI calculator with tests")
23
+ return (
24
+ "HandoffKit demo\n"
25
+ "Protocol: hybrid_state\n"
26
+ f"Agents: {', '.join(agent.name for agent in team.agents)}\n"
27
+ f"Handoffs: {len(result.handoffs)}\n"
28
+ f"Final output:\n{result.final_output}"
29
+ )
30
+
31
+
32
+ def main(argv: list[str] | None = None) -> int:
33
+ """Run the command-line interface."""
34
+ parser = argparse.ArgumentParser(prog="handoffkit")
35
+ parser.add_argument("--version", action="version", version=f"handoffkit {__version__}")
36
+ subparsers = parser.add_subparsers(dest="command")
37
+ subparsers.add_parser("demo", help="Run a local EchoProvider demo.")
38
+ args = parser.parse_args(argv)
39
+
40
+ if args.command == "demo":
41
+ print(run_demo())
42
+ return 0
43
+
44
+ parser.print_help()
45
+ return 0
46
+
47
+
48
+ if __name__ == "__main__":
49
+ raise SystemExit(main())
handoffkit/errors.py ADDED
@@ -0,0 +1,33 @@
1
+ """Package-specific exceptions."""
2
+
3
+
4
+ class StateTransferError(Exception):
5
+ """Base exception for handoffkit."""
6
+
7
+
8
+ class AgentError(StateTransferError):
9
+ """Raised when an agent cannot complete an operation."""
10
+
11
+
12
+ class ProtocolError(StateTransferError):
13
+ """Raised when a handoff protocol is invalid or cannot run."""
14
+
15
+
16
+ class HandoffValidationError(StateTransferError):
17
+ """Raised when a handoff state violates the expected contract."""
18
+
19
+
20
+ class ToolExecutionError(StateTransferError):
21
+ """Raised when a tool fails during execution."""
22
+
23
+
24
+ class DangerousCommandError(StateTransferError):
25
+ """Raised when a shell command is blocked by the safety policy."""
26
+
27
+
28
+ class ProviderConfigurationError(StateTransferError):
29
+ """Raised when a provider is missing required configuration."""
30
+
31
+
32
+ class ProviderExecutionError(StateTransferError):
33
+ """Raised when a provider request fails."""
handoffkit/handoff.py ADDED
@@ -0,0 +1,86 @@
1
+ """Structured handoff state between agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, dataclass, field
7
+ from typing import Any
8
+
9
+ from handoffkit.errors import HandoffValidationError
10
+
11
+ REQUIRED_TEXT_FIELDS = ("task", "from_agent", "to_agent")
12
+ LIST_FIELDS = ("decisions", "important_files", "errors", "next_steps")
13
+
14
+
15
+ def _value_or_default(data: dict[str, Any], key: str, default: Any) -> Any:
16
+ """Read a key without coercing invalid caller data into a valid shape."""
17
+ value = data.get(key, default)
18
+ return default if value is None else value
19
+
20
+
21
+ @dataclass
22
+ class HandoffState:
23
+ """State transferred from one agent to another."""
24
+
25
+ task: str
26
+ from_agent: str
27
+ to_agent: str
28
+ summary: str = ""
29
+ decisions: list[str] = field(default_factory=list)
30
+ important_files: list[str] = field(default_factory=list)
31
+ errors: list[str] = field(default_factory=list)
32
+ next_steps: list[str] = field(default_factory=list)
33
+ metadata: dict[str, Any] = field(default_factory=dict)
34
+
35
+ def to_dict(self) -> dict[str, Any]:
36
+ """Return a JSON-serializable dictionary."""
37
+ return asdict(self)
38
+
39
+ def to_json(self, *, indent: int | None = 2) -> str:
40
+ """Return a JSON string."""
41
+ return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
42
+
43
+ def validate(self) -> HandoffState:
44
+ """Validate the handoff state contract and return self."""
45
+ problems: list[str] = []
46
+ for field_name in REQUIRED_TEXT_FIELDS:
47
+ value = getattr(self, field_name)
48
+ if not isinstance(value, str) or not value.strip():
49
+ problems.append(f"{field_name} must be a non-empty string")
50
+ if not isinstance(self.summary, str):
51
+ problems.append("summary must be a string")
52
+ for field_name in LIST_FIELDS:
53
+ value = getattr(self, field_name)
54
+ if not isinstance(value, list):
55
+ problems.append(f"{field_name} must be a list")
56
+ continue
57
+ if not all(isinstance(item, str) for item in value):
58
+ problems.append(f"{field_name} must contain only strings")
59
+ if not isinstance(self.metadata, dict):
60
+ problems.append("metadata must be a dictionary")
61
+ if problems:
62
+ raise HandoffValidationError("; ".join(problems))
63
+ return self
64
+
65
+ @classmethod
66
+ def from_dict(cls, data: dict[str, Any]) -> HandoffState:
67
+ """Create a handoff state from a dictionary."""
68
+ return cls(
69
+ task=_value_or_default(data, "task", ""),
70
+ from_agent=_value_or_default(data, "from_agent", ""),
71
+ to_agent=_value_or_default(data, "to_agent", ""),
72
+ summary=_value_or_default(data, "summary", ""),
73
+ decisions=_value_or_default(data, "decisions", []),
74
+ important_files=_value_or_default(data, "important_files", []),
75
+ errors=_value_or_default(data, "errors", []),
76
+ next_steps=_value_or_default(data, "next_steps", []),
77
+ metadata=_value_or_default(data, "metadata", {}),
78
+ )
79
+
80
+ @classmethod
81
+ def from_json(cls, value: str) -> HandoffState:
82
+ """Create a handoff state from JSON."""
83
+ data = json.loads(value)
84
+ if not isinstance(data, dict):
85
+ raise ValueError("HandoffState JSON must decode to an object.")
86
+ return cls.from_dict(data)
handoffkit/memory.py ADDED
@@ -0,0 +1,44 @@
1
+ """Simple in-memory agent memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class MemoryEntry:
12
+ """One memory entry created by an agent run."""
13
+
14
+ role: str
15
+ content: str
16
+ metadata: dict[str, Any] = field(default_factory=dict)
17
+ created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
18
+
19
+
20
+ @dataclass
21
+ class AgentMemory:
22
+ """Small append-only memory used by agents."""
23
+
24
+ entries: list[MemoryEntry] = field(default_factory=list)
25
+
26
+ def add(self, role: str, content: str, **metadata: Any) -> MemoryEntry:
27
+ """Add an entry and return it."""
28
+ entry = MemoryEntry(role=role, content=content, metadata=metadata)
29
+ self.entries.append(entry)
30
+ return entry
31
+
32
+ def last(self, count: int = 5) -> list[MemoryEntry]:
33
+ """Return the most recent entries."""
34
+ if count <= 0:
35
+ return []
36
+ return self.entries[-count:]
37
+
38
+ def to_text(self, count: int = 5) -> str:
39
+ """Render recent memory entries as compact text."""
40
+ return "\n".join(f"{entry.role}: {entry.content}" for entry in self.last(count))
41
+
42
+ def clear(self) -> None:
43
+ """Remove all entries."""
44
+ self.entries.clear()
handoffkit/protocol.py ADDED
@@ -0,0 +1,60 @@
1
+ """Handoff protocol facade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from handoffkit.agent import Agent
8
+ from handoffkit.errors import ProtocolError
9
+ from handoffkit.handoff import HandoffState
10
+ from handoffkit.protocols import compressed, hybrid_min, hybrid_state, natural
11
+ from handoffkit.schemas import ProtocolMode
12
+
13
+ SUPPORTED_MODES: set[str] = {"natural", "compressed", "hybrid_min", "hybrid_state"}
14
+
15
+
16
+ class HandoffProtocol:
17
+ """Transfer state between agents using a named protocol mode."""
18
+
19
+ def __init__(self, mode: ProtocolMode = "hybrid_state") -> None:
20
+ if mode not in SUPPORTED_MODES:
21
+ raise ProtocolError(f"Unsupported handoff protocol mode: {mode}")
22
+ self.mode = mode
23
+
24
+ def transfer(
25
+ self,
26
+ *,
27
+ from_agent: Agent,
28
+ to_agent: Agent,
29
+ task: str,
30
+ summary: str | None = None,
31
+ decisions: list[str] | None = None,
32
+ important_files: list[str] | None = None,
33
+ errors: list[str] | None = None,
34
+ next_steps: list[str] | None = None,
35
+ metadata: dict[str, Any] | None = None,
36
+ ) -> HandoffState:
37
+ """Create handoff state from one agent to another."""
38
+ source_summary = summary if summary is not None else from_agent.run(task)
39
+ if self.mode == "natural":
40
+ return natural.build_state(task, from_agent.name, to_agent.name, source_summary)
41
+ if self.mode == "compressed":
42
+ return compressed.build_state(task, from_agent.name, to_agent.name, source_summary)
43
+ if self.mode == "hybrid_min":
44
+ return hybrid_min.build_state(task, from_agent.name, to_agent.name, source_summary)
45
+ if self.mode == "hybrid_state":
46
+ return hybrid_state.build_state(
47
+ task,
48
+ from_agent.name,
49
+ to_agent.name,
50
+ source_summary,
51
+ decisions=decisions,
52
+ important_files=important_files,
53
+ errors=errors,
54
+ next_steps=next_steps,
55
+ metadata=metadata,
56
+ )
57
+ raise ProtocolError(f"Unsupported handoff protocol mode: {self.mode}")
58
+
59
+ def __repr__(self) -> str:
60
+ return f"HandoffProtocol(mode={self.mode!r})"
@@ -0,0 +1,5 @@
1
+ """Protocol module exports."""
2
+
3
+ from handoffkit.protocols import compressed, hybrid_min, hybrid_state, natural
4
+
5
+ __all__ = ["compressed", "hybrid_min", "hybrid_state", "natural"]
@@ -0,0 +1,31 @@
1
+ """Compressed handoff protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from handoffkit.handoff import HandoffState
6
+
7
+
8
+ def compact(text: str, max_chars: int = 360) -> str:
9
+ """Compact whitespace and cap text length."""
10
+ clean = " ".join(text.strip().split())
11
+ if len(clean) <= max_chars:
12
+ return clean
13
+ return clean[: max_chars - 3].rstrip() + "..."
14
+
15
+
16
+ def build_state(task: str, from_agent: str, to_agent: str, summary: str) -> HandoffState:
17
+ """Build a compact handoff state."""
18
+ return HandoffState(
19
+ task=compact(task, 220),
20
+ from_agent=from_agent,
21
+ to_agent=to_agent,
22
+ summary=compact(summary, 360),
23
+ next_steps=[
24
+ compact(f"{to_agent}: continue task; preserve constraints; report blockers.", 180)
25
+ ],
26
+ metadata={
27
+ "mode": "compressed",
28
+ "format": "compact",
29
+ "compression": "whitespace-normalized-char-cap",
30
+ },
31
+ )
@@ -0,0 +1,17 @@
1
+ """Minimal hybrid handoff protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from handoffkit.handoff import HandoffState
6
+
7
+
8
+ def build_state(task: str, from_agent: str, to_agent: str, summary: str) -> HandoffState:
9
+ """Build a minimal structured handoff state."""
10
+ return HandoffState(
11
+ task=task,
12
+ from_agent=from_agent,
13
+ to_agent=to_agent,
14
+ summary=summary,
15
+ next_steps=[f"{to_agent} should use task and summary as the minimal state contract."],
16
+ metadata={"mode": "hybrid_min", "fields": ["task", "summary", "next_steps"]},
17
+ )
@@ -0,0 +1,52 @@
1
+ """Full structured state handoff protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from handoffkit.handoff import HandoffState
8
+
9
+
10
+ def build_state(
11
+ task: str,
12
+ from_agent: str,
13
+ to_agent: str,
14
+ summary: str,
15
+ *,
16
+ decisions: list[str] | None = None,
17
+ important_files: list[str] | None = None,
18
+ errors: list[str] | None = None,
19
+ next_steps: list[str] | None = None,
20
+ metadata: dict[str, Any] | None = None,
21
+ ) -> HandoffState:
22
+ """Build a full structured handoff state."""
23
+ merged_metadata = {
24
+ "mode": "hybrid_state",
25
+ "handoff_version": "1.0",
26
+ "state_contract": [
27
+ "task",
28
+ "from_agent",
29
+ "to_agent",
30
+ "summary",
31
+ "decisions",
32
+ "important_files",
33
+ "errors",
34
+ "next_steps",
35
+ "metadata",
36
+ ],
37
+ }
38
+ merged_metadata.update(metadata or {})
39
+ return HandoffState(
40
+ task=task,
41
+ from_agent=from_agent,
42
+ to_agent=to_agent,
43
+ summary=summary,
44
+ decisions=decisions or [f"{from_agent} completed its current role output."],
45
+ important_files=important_files or [],
46
+ errors=errors or [],
47
+ next_steps=next_steps or [
48
+ f"{to_agent} should inspect the structured handoff.",
49
+ "Continue the task while preserving decisions and constraints.",
50
+ ],
51
+ metadata=merged_metadata,
52
+ )
@@ -0,0 +1,22 @@
1
+ """Natural-language handoff protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from handoffkit.handoff import HandoffState
6
+
7
+
8
+ def build_state(task: str, from_agent: str, to_agent: str, summary: str) -> HandoffState:
9
+ """Build a natural-language handoff state."""
10
+ natural_summary = (
11
+ f"{from_agent} is handing off to {to_agent}. "
12
+ f"The original task is: {task}. "
13
+ f"Progress summary: {summary}"
14
+ )
15
+ return HandoffState(
16
+ task=task,
17
+ from_agent=from_agent,
18
+ to_agent=to_agent,
19
+ summary=natural_summary,
20
+ next_steps=[f"{to_agent} should continue from the summary and preserve task intent."],
21
+ metadata={"mode": "natural", "format": "human_readable"},
22
+ )
@@ -0,0 +1,21 @@
1
+ """Provider exports."""
2
+
3
+ from handoffkit.providers.base import BaseProvider
4
+ from handoffkit.providers.echo_provider import EchoProvider
5
+ from handoffkit.providers.ollama_provider import OllamaProvider
6
+ from handoffkit.providers.openai_compatible import (
7
+ ModelSelectionResult,
8
+ choose_openai_compatible_model,
9
+ list_openai_compatible_models,
10
+ )
11
+ from handoffkit.providers.openai_provider import OpenAIProvider
12
+
13
+ __all__ = [
14
+ "BaseProvider",
15
+ "EchoProvider",
16
+ "ModelSelectionResult",
17
+ "OllamaProvider",
18
+ "OpenAIProvider",
19
+ "choose_openai_compatible_model",
20
+ "list_openai_compatible_models",
21
+ ]
@@ -0,0 +1,16 @@
1
+ """Provider interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any
7
+
8
+
9
+ class BaseProvider(ABC):
10
+ """Base class for text generation providers."""
11
+
12
+ model: str
13
+
14
+ @abstractmethod
15
+ def generate(self, prompt: str, **kwargs: Any) -> str:
16
+ """Generate a response for a prompt."""
@@ -0,0 +1,26 @@
1
+ """Local provider that returns deterministic useful text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from handoffkit.providers.base import BaseProvider
8
+
9
+
10
+ class EchoProvider(BaseProvider):
11
+ """Provider for local tests and demos without external APIs."""
12
+
13
+ def __init__(self, model: str = "echo") -> None:
14
+ self.model = model
15
+
16
+ def generate(self, prompt: str, **kwargs: Any) -> str:
17
+ """Return a deterministic response summarizing the prompt."""
18
+ preview = " ".join(prompt.strip().split())
19
+ if len(preview) > 420:
20
+ preview = preview[:417].rstrip() + "..."
21
+ return (
22
+ f"EchoProvider[{self.model}] response\n"
23
+ f"Summary: {preview}\n"
24
+ "Decisions: keep state explicit, preserve constraints, continue with next step.\n"
25
+ "Next steps: inspect the handoff state, act on the task, report errors."
26
+ )
@@ -0,0 +1,48 @@
1
+ """Ollama local HTTP provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.error
7
+ import urllib.request
8
+ from typing import Any
9
+
10
+ from handoffkit.errors import ProviderExecutionError
11
+ from handoffkit.providers.base import BaseProvider
12
+
13
+
14
+ class OllamaProvider(BaseProvider):
15
+ """Provider for a local Ollama server."""
16
+
17
+ def __init__(
18
+ self,
19
+ model: str = "llama3.1",
20
+ *,
21
+ base_url: str = "http://localhost:11434",
22
+ timeout: float = 60.0,
23
+ ) -> None:
24
+ self.model = model
25
+ self.base_url = base_url.rstrip("/")
26
+ self.timeout = timeout
27
+
28
+ def generate(self, prompt: str, **kwargs: Any) -> str:
29
+ """Generate text using Ollama's `/api/generate` endpoint."""
30
+ payload = {
31
+ "model": kwargs.pop("model", self.model),
32
+ "prompt": prompt,
33
+ "stream": False,
34
+ **kwargs,
35
+ }
36
+ data = json.dumps(payload).encode("utf-8")
37
+ request = urllib.request.Request(
38
+ f"{self.base_url}/api/generate",
39
+ data=data,
40
+ headers={"Content-Type": "application/json"},
41
+ method="POST",
42
+ )
43
+ try:
44
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
45
+ body = json.loads(response.read().decode("utf-8"))
46
+ except urllib.error.URLError as exc:
47
+ raise ProviderExecutionError(f"Ollama request failed: {exc}") from exc
48
+ return str(body.get("response", ""))