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
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,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", ""))
|