evidor 1.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.
- evidor/__init__.py +26 -0
- evidor/agent/__init__.py +5 -0
- evidor/agent/agent.py +123 -0
- evidor/agent/history.py +33 -0
- evidor/agent/summarization.py +69 -0
- evidor/agent/tool_executor.py +48 -0
- evidor/agent/utils.py +18 -0
- evidor/context.py +66 -0
- evidor/models.py +87 -0
- evidor/providers/__init__.py +8 -0
- evidor/providers/anthropic.py +99 -0
- evidor/providers/base.py +18 -0
- evidor/providers/gemini.py +101 -0
- evidor/providers/openai.py +144 -0
- evidor/tools/__init__.py +19 -0
- evidor/tools/core/__init__.py +5 -0
- evidor/tools/core/tools.py +122 -0
- evidor/tools/native/__init__.py +7 -0
- evidor/tools/native/calculator.py +59 -0
- evidor/tools/native/filesystem/__init__.py +40 -0
- evidor/tools/native/filesystem/_common.py +30 -0
- evidor/tools/native/filesystem/list_files.py +51 -0
- evidor/tools/native/filesystem/mutate_files.py +69 -0
- evidor/tools/native/filesystem/read_file.py +31 -0
- evidor/tools/native/filesystem/search_files.py +66 -0
- evidor/tools/native/get_current_time.py +20 -0
- evidor/utils/__init__.py +14 -0
- evidor/utils/execution.py +46 -0
- evidor/utils/schema.py +95 -0
- evidor-1.1.0.dist-info/METADATA +403 -0
- evidor-1.1.0.dist-info/RECORD +33 -0
- evidor-1.1.0.dist-info/WHEEL +4 -0
- evidor-1.1.0.dist-info/licenses/LICENSE +21 -0
evidor/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Minimal, provider-agnostic LLM harness."""
|
|
2
|
+
|
|
3
|
+
from .agent import Agent
|
|
4
|
+
from .context import DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_MESSAGES
|
|
5
|
+
from .models import GenerationRequest, GenerationResponse, Message, ToolCall
|
|
6
|
+
from .providers import AnthropicProvider, GeminiProvider, ModelProvider, OpenAIProvider
|
|
7
|
+
from .tools import Tool, calculator, filesystem_tools, get_current_time, tool
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Agent",
|
|
11
|
+
"AnthropicProvider",
|
|
12
|
+
"calculator",
|
|
13
|
+
"DEFAULT_CONTEXT_WINDOW",
|
|
14
|
+
"DEFAULT_MAX_MESSAGES",
|
|
15
|
+
"GeminiProvider",
|
|
16
|
+
"GenerationRequest",
|
|
17
|
+
"GenerationResponse",
|
|
18
|
+
"get_current_time",
|
|
19
|
+
"filesystem_tools",
|
|
20
|
+
"Message",
|
|
21
|
+
"ModelProvider",
|
|
22
|
+
"OpenAIProvider",
|
|
23
|
+
"Tool",
|
|
24
|
+
"ToolCall",
|
|
25
|
+
"tool",
|
|
26
|
+
]
|
evidor/agent/__init__.py
ADDED
evidor/agent/agent.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""The public conversational agent facade."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Callable, Sequence
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..context import DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_MESSAGES, ConversationContext
|
|
8
|
+
from ..models import GenerationRequest, GenerationResponse, Message, ToolCall
|
|
9
|
+
from ..providers.base import ModelProvider
|
|
10
|
+
from ..tools import Tool
|
|
11
|
+
from .history import ConversationHistory
|
|
12
|
+
from .summarization import ConversationSummarizer, resolve_summary_provider
|
|
13
|
+
from .tool_executor import ToolExecutor
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Agent:
|
|
17
|
+
"""A conversational session coordinating a provider, history, context, and tools."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
provider: ModelProvider,
|
|
22
|
+
*,
|
|
23
|
+
tools: Sequence[Tool | Callable[..., Any]] | None = None,
|
|
24
|
+
max_tool_iterations: int = 10,
|
|
25
|
+
tool_timeout: float | None = None,
|
|
26
|
+
system_prompt: str | None = None,
|
|
27
|
+
context_window: int = DEFAULT_CONTEXT_WINDOW,
|
|
28
|
+
max_messages: int = DEFAULT_MAX_MESSAGES,
|
|
29
|
+
summarization_model: str | ModelProvider | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._provider = provider
|
|
32
|
+
self._summary_provider = resolve_summary_provider(provider, summarization_model)
|
|
33
|
+
self._summarizer = ConversationSummarizer(self._summary_provider)
|
|
34
|
+
self._context = ConversationContext(context_window, max_messages)
|
|
35
|
+
self._history = ConversationHistory(system_prompt)
|
|
36
|
+
# Retained for compatibility with callers that inspect or seed test history.
|
|
37
|
+
self._messages = self._history.messages
|
|
38
|
+
self._max_tool_iterations = max_tool_iterations
|
|
39
|
+
self._tool_timeout = tool_timeout
|
|
40
|
+
self._tool_executor = ToolExecutor(tools, tool_timeout)
|
|
41
|
+
self._tools = self._tool_executor.tools
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def messages(self) -> tuple[Message, ...]:
|
|
45
|
+
"""The retained conversation, including any generated summary message."""
|
|
46
|
+
return tuple(self._messages)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def tools(self) -> tuple[Tool, ...]:
|
|
50
|
+
"""The tools configured on this agent."""
|
|
51
|
+
return self._tools
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def tool_timeout(self) -> float | None:
|
|
55
|
+
"""Default per-tool execution timeout in seconds, if configured."""
|
|
56
|
+
return self._tool_timeout
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def summary_provider(self) -> ModelProvider:
|
|
60
|
+
"""The provider used for compacting and summarizing conversations."""
|
|
61
|
+
return self._summary_provider
|
|
62
|
+
|
|
63
|
+
def send(self, message: str) -> GenerationResponse:
|
|
64
|
+
"""Advance one user turn and execute any requested tool calls."""
|
|
65
|
+
self._history.add_user(message)
|
|
66
|
+
for _ in range(self._max_tool_iterations):
|
|
67
|
+
self._compact()
|
|
68
|
+
response = self._provider.generate(GenerationRequest(messages=self._messages, tools=self._tools))
|
|
69
|
+
self._history.add_assistant(response)
|
|
70
|
+
if not response.tool_calls:
|
|
71
|
+
return response
|
|
72
|
+
self._execute_tool_calls(response.tool_calls)
|
|
73
|
+
self._compact()
|
|
74
|
+
final_response = self._provider.generate(GenerationRequest(messages=self._messages, tools=()))
|
|
75
|
+
return self._finalize_loop_expiry(final_response, response)
|
|
76
|
+
|
|
77
|
+
async def send_async(self, message: str) -> GenerationResponse:
|
|
78
|
+
"""Asynchronously advance one user turn and execute requested tool calls."""
|
|
79
|
+
self._history.add_user(message)
|
|
80
|
+
for _ in range(self._max_tool_iterations):
|
|
81
|
+
await self._compact_async()
|
|
82
|
+
response = await asyncio.to_thread(
|
|
83
|
+
self._provider.generate, GenerationRequest(messages=self._messages, tools=self._tools)
|
|
84
|
+
)
|
|
85
|
+
self._history.add_assistant(response)
|
|
86
|
+
if not response.tool_calls:
|
|
87
|
+
return response
|
|
88
|
+
await self._execute_tool_calls_async(response.tool_calls)
|
|
89
|
+
await self._compact_async()
|
|
90
|
+
final_response = await asyncio.to_thread(
|
|
91
|
+
self._provider.generate, GenerationRequest(messages=self._messages, tools=())
|
|
92
|
+
)
|
|
93
|
+
return self._finalize_loop_expiry(final_response, response)
|
|
94
|
+
|
|
95
|
+
def clear_history(self) -> None:
|
|
96
|
+
"""Clear the conversation while retaining the optional system prompt."""
|
|
97
|
+
self._history.clear()
|
|
98
|
+
|
|
99
|
+
def _compact(self) -> None:
|
|
100
|
+
self._history.replace(self._context.compact(self._messages, self._summarizer.summarize))
|
|
101
|
+
|
|
102
|
+
async def _compact_async(self) -> None:
|
|
103
|
+
compacted = await asyncio.to_thread(self._context.compact, self._messages, self._summarizer.summarize)
|
|
104
|
+
self._history.replace(compacted)
|
|
105
|
+
|
|
106
|
+
def _execute_tool_calls(self, calls: Sequence[ToolCall]) -> None:
|
|
107
|
+
for call in calls:
|
|
108
|
+
self._history.add_tool_result(call, self._tool_executor.execute(call))
|
|
109
|
+
|
|
110
|
+
async def _execute_tool_calls_async(self, calls: Sequence[ToolCall]) -> None:
|
|
111
|
+
for call in calls:
|
|
112
|
+
result = await self._tool_executor.execute_async(call)
|
|
113
|
+
self._history.add_tool_result(call, result)
|
|
114
|
+
|
|
115
|
+
def _finalize_loop_expiry(
|
|
116
|
+
self, final_response: GenerationResponse, last_response: GenerationResponse
|
|
117
|
+
) -> GenerationResponse:
|
|
118
|
+
final_text = (final_response.text or "").strip() or (last_response.text or "").strip()
|
|
119
|
+
if not final_text:
|
|
120
|
+
final_text = f"Reached maximum tool iterations ({self._max_tool_iterations}) without a final response."
|
|
121
|
+
self._messages.append(Message(role="assistant", content=final_text))
|
|
122
|
+
return GenerationResponse(text=final_text, model=final_response.model or last_response.model)
|
|
123
|
+
|
evidor/agent/history.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Conversation message storage and recording helpers."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
from ..models import GenerationResponse, Message, ToolCall
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConversationHistory:
|
|
9
|
+
"""Owns the mutable transcript for a single agent session."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, system_prompt: str | None = None) -> None:
|
|
12
|
+
self.messages: list[Message] = []
|
|
13
|
+
if system_prompt:
|
|
14
|
+
self.messages.append(Message(role="system", content=system_prompt))
|
|
15
|
+
|
|
16
|
+
def add_user(self, content: str) -> None:
|
|
17
|
+
self.messages.append(Message(role="user", content=content))
|
|
18
|
+
|
|
19
|
+
def add_assistant(self, response: GenerationResponse) -> None:
|
|
20
|
+
self.messages.append(Message(role="assistant", content=response.text, tool_calls=response.tool_calls))
|
|
21
|
+
|
|
22
|
+
def add_tool_result(self, call: ToolCall, result: str) -> None:
|
|
23
|
+
self.messages.append(Message(role="tool", content=result, tool_call_id=call.id, name=call.name))
|
|
24
|
+
|
|
25
|
+
def replace(self, messages: Sequence[Message]) -> None:
|
|
26
|
+
"""Replace history in place so existing list references remain valid."""
|
|
27
|
+
self.messages[:] = messages
|
|
28
|
+
|
|
29
|
+
def clear(self) -> None:
|
|
30
|
+
"""Remove conversation turns while retaining the original system prompt."""
|
|
31
|
+
self.messages[:] = [
|
|
32
|
+
message for message in self.messages if message.role == "system" and not message.is_summary
|
|
33
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Conversation summarization and summary-provider configuration."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
from ..models import GenerationRequest, Message
|
|
6
|
+
from ..providers.base import ModelProvider
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
SUMMARY_INSTRUCTION = (
|
|
10
|
+
"Summarize the conversation below for a future assistant turn. Preserve user goals, "
|
|
11
|
+
"decisions, constraints, factual details, and unresolved questions. Be concise."
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConversationSummarizer:
|
|
16
|
+
"""Formats a transcript and asks a dedicated provider to summarize it."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, provider: ModelProvider) -> None:
|
|
19
|
+
self.provider = provider
|
|
20
|
+
|
|
21
|
+
def summarize(self, messages: Sequence[Message]) -> str:
|
|
22
|
+
transcript_lines: list[str] = []
|
|
23
|
+
for message in messages:
|
|
24
|
+
if message.role == "tool":
|
|
25
|
+
transcript_lines.append(f"TOOL ({message.name or 'unknown'}): {message.content}")
|
|
26
|
+
elif message.tool_calls:
|
|
27
|
+
call_names = ", ".join(call.name for call in message.tool_calls)
|
|
28
|
+
prefix = f"ASSISTANT (called tools: {call_names}):"
|
|
29
|
+
transcript_lines.append(f"{prefix} {message.content}" if message.content else prefix)
|
|
30
|
+
else:
|
|
31
|
+
transcript_lines.append(f"{message.role.upper()}: {message.content}")
|
|
32
|
+
request = GenerationRequest(
|
|
33
|
+
messages=(
|
|
34
|
+
Message(role="system", content=SUMMARY_INSTRUCTION),
|
|
35
|
+
Message(role="user", content="\n".join(transcript_lines)),
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
return self.provider.generate(request).text
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_summary_provider(
|
|
42
|
+
provider: ModelProvider, summarization_model: str | ModelProvider | None
|
|
43
|
+
) -> ModelProvider:
|
|
44
|
+
"""Select a summary provider from a provider instance or model-name override."""
|
|
45
|
+
if summarization_model is None:
|
|
46
|
+
return provider
|
|
47
|
+
if isinstance(summarization_model, ModelProvider):
|
|
48
|
+
return summarization_model
|
|
49
|
+
if isinstance(summarization_model, str):
|
|
50
|
+
if not summarization_model.strip():
|
|
51
|
+
raise ValueError("summarization_model must not be empty")
|
|
52
|
+
with_model = getattr(provider, "with_model", None)
|
|
53
|
+
if callable(with_model):
|
|
54
|
+
return with_model(summarization_model)
|
|
55
|
+
provider_type = type(provider)
|
|
56
|
+
api_key = getattr(provider, "_api_key", None)
|
|
57
|
+
try:
|
|
58
|
+
return provider_type(model=summarization_model, api_key=api_key) # type: ignore[call-arg]
|
|
59
|
+
except TypeError:
|
|
60
|
+
try:
|
|
61
|
+
return provider_type(model=summarization_model) # type: ignore[call-arg]
|
|
62
|
+
except TypeError as err:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"Cannot configure summarization_model string '{summarization_model}' for provider {provider_type.__name__}. "
|
|
65
|
+
"Pass a ModelProvider instance or implement with_model(model)."
|
|
66
|
+
) from err
|
|
67
|
+
raise TypeError(
|
|
68
|
+
f"summarization_model must be a model name string or ModelProvider, got {type(summarization_model).__name__}"
|
|
69
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Tool registration and provider-requested tool execution."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..models import ToolCall
|
|
7
|
+
from ..tools import Tool, tool
|
|
8
|
+
from .utils import format_tool_error, serialize_tool_result
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ToolExecutor:
|
|
12
|
+
"""Normalizes configured tools and executes calls requested by a provider."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, tools: Sequence[Tool | Callable[..., Any]] | None, default_timeout: float | None) -> None:
|
|
15
|
+
configured_tools: list[Tool] = []
|
|
16
|
+
for candidate in tools or ():
|
|
17
|
+
configured = candidate if isinstance(candidate, Tool) else tool(candidate)
|
|
18
|
+
if default_timeout is not None and configured.timeout is None:
|
|
19
|
+
configured = configured.with_timeout(default_timeout)
|
|
20
|
+
configured_tools.append(configured)
|
|
21
|
+
self.tools = tuple(configured_tools)
|
|
22
|
+
self._by_name = {configured.name: configured for configured in self.tools}
|
|
23
|
+
|
|
24
|
+
def execute(self, call: ToolCall) -> str:
|
|
25
|
+
target = self._resolve(call)
|
|
26
|
+
if isinstance(target, str):
|
|
27
|
+
return target
|
|
28
|
+
try:
|
|
29
|
+
return serialize_tool_result(target.execute(**call.arguments))
|
|
30
|
+
except Exception as err:
|
|
31
|
+
return format_tool_error(call.name, err)
|
|
32
|
+
|
|
33
|
+
async def execute_async(self, call: ToolCall) -> str:
|
|
34
|
+
target = self._resolve(call)
|
|
35
|
+
if isinstance(target, str):
|
|
36
|
+
return target
|
|
37
|
+
try:
|
|
38
|
+
return serialize_tool_result(await target.execute_async(**call.arguments))
|
|
39
|
+
except Exception as err:
|
|
40
|
+
return format_tool_error(call.name, err)
|
|
41
|
+
|
|
42
|
+
def _resolve(self, call: ToolCall) -> Tool | str:
|
|
43
|
+
if "__decode_error__" in call.arguments:
|
|
44
|
+
decode_error = call.arguments["__decode_error__"]
|
|
45
|
+
raw_input = call.arguments.get("__raw_args__", "")
|
|
46
|
+
return f"Error: Malformed JSON arguments for tool '{call.name}': {decode_error}. Received input: {raw_input}"
|
|
47
|
+
target = self._by_name.get(call.name)
|
|
48
|
+
return target if target is not None else f"Error: Tool '{call.name}' not found."
|
evidor/agent/utils.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Utilities used exclusively by agent-runtime components."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def serialize_tool_result(result: Any) -> str:
|
|
8
|
+
"""Serialize a tool execution result into a string suitable for LLM consumption."""
|
|
9
|
+
if isinstance(result, str):
|
|
10
|
+
return result
|
|
11
|
+
if isinstance(result, (dict, list, int, float, bool)):
|
|
12
|
+
return json.dumps(result)
|
|
13
|
+
return str(result)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def format_tool_error(name: str, err: Any) -> str:
|
|
17
|
+
"""Format a standardized error string when tool execution fails."""
|
|
18
|
+
return f"Error executing tool '{name}': {err}"
|
evidor/context.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Conversation-window management and summarization."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Sequence
|
|
4
|
+
|
|
5
|
+
from .models import Message
|
|
6
|
+
|
|
7
|
+
DEFAULT_CONTEXT_WINDOW = 16_000
|
|
8
|
+
DEFAULT_MAX_MESSAGES = 50
|
|
9
|
+
_CHARS_PER_TOKEN = 4
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConversationContext:
|
|
13
|
+
"""Keeps recent messages and replaces older messages with a bounded summary."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, context_window: int, max_messages: int) -> None:
|
|
16
|
+
if context_window < 128:
|
|
17
|
+
raise ValueError("context_window must be at least 128 tokens")
|
|
18
|
+
if max_messages < 3:
|
|
19
|
+
raise ValueError("max_messages must be at least 3")
|
|
20
|
+
self._context_window = context_window
|
|
21
|
+
self._max_messages = max_messages
|
|
22
|
+
|
|
23
|
+
def compact(
|
|
24
|
+
self,
|
|
25
|
+
messages: Sequence[Message],
|
|
26
|
+
summarize: Callable[[Sequence[Message]], str],
|
|
27
|
+
) -> list[Message]:
|
|
28
|
+
"""Return messages that fit the configured input budget."""
|
|
29
|
+
if not self._requires_compaction(messages):
|
|
30
|
+
return list(messages)
|
|
31
|
+
|
|
32
|
+
permanent = [message for message in messages if message.role == "system" and not message.is_summary]
|
|
33
|
+
existing_summaries = [message for message in messages if message.is_summary]
|
|
34
|
+
conversation = [message for message in messages if message.role != "system"]
|
|
35
|
+
recent: list[Message] = list(conversation)
|
|
36
|
+
summarized: list[Message] = list(existing_summaries)
|
|
37
|
+
|
|
38
|
+
recent_limit = max(1, self._max_messages - len(permanent) - 1)
|
|
39
|
+
while len(recent) > recent_limit or self._estimated_tokens(permanent + recent) > self._input_budget:
|
|
40
|
+
if len(recent) == 1:
|
|
41
|
+
break
|
|
42
|
+
summarized.append(recent.pop(0))
|
|
43
|
+
|
|
44
|
+
if not summarized:
|
|
45
|
+
return permanent + recent
|
|
46
|
+
|
|
47
|
+
summary = summarize(summarized).strip()
|
|
48
|
+
if not summary:
|
|
49
|
+
summary = "Earlier conversation was omitted because no summary was returned."
|
|
50
|
+
summary = summary[: self._summary_token_limit * _CHARS_PER_TOKEN]
|
|
51
|
+
return permanent + [Message(role="system", content=f"Conversation summary:\n{summary}", is_summary=True)] + recent
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def _input_budget(self) -> int:
|
|
55
|
+
return self._context_window - min(1_024, self._context_window // 4)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def _summary_token_limit(self) -> int:
|
|
59
|
+
return min(1_024, max(32, self._context_window // 4))
|
|
60
|
+
|
|
61
|
+
def _requires_compaction(self, messages: Sequence[Message]) -> bool:
|
|
62
|
+
return len(messages) > self._max_messages or self._estimated_tokens(messages) > self._input_budget
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _estimated_tokens(messages: Sequence[Message]) -> int:
|
|
66
|
+
return sum((len(message.content) + _CHARS_PER_TOKEN - 1) // _CHARS_PER_TOKEN + 4 for message in messages)
|
evidor/models.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Provider-neutral data structures."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from .tools import Tool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
MessageRole = Literal["system", "user", "assistant", "tool"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class ToolCall:
|
|
15
|
+
"""A tool invocation requested by a model."""
|
|
16
|
+
|
|
17
|
+
id: str
|
|
18
|
+
name: str
|
|
19
|
+
arguments: dict[str, Any]
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
if not self.id:
|
|
23
|
+
raise ValueError("tool call id must not be empty")
|
|
24
|
+
if not self.name:
|
|
25
|
+
raise ValueError("tool call name must not be empty")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class Message:
|
|
30
|
+
"""A single text message or tool call/response in a conversation."""
|
|
31
|
+
|
|
32
|
+
role: MessageRole
|
|
33
|
+
content: str = ""
|
|
34
|
+
tool_calls: tuple[ToolCall, ...] = ()
|
|
35
|
+
tool_call_id: str | None = None
|
|
36
|
+
name: str | None = None
|
|
37
|
+
is_summary: bool = False
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
if isinstance(self.tool_calls, (list, tuple)):
|
|
41
|
+
object.__setattr__(self, "tool_calls", tuple(self.tool_calls))
|
|
42
|
+
if (not self.content or not self.content.strip()) and not self.tool_calls and self.role != "tool":
|
|
43
|
+
raise ValueError("message content must not be empty")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True, init=False)
|
|
47
|
+
class GenerationRequest:
|
|
48
|
+
"""The complete provider-neutral input for one model generation."""
|
|
49
|
+
|
|
50
|
+
messages: tuple[Message, ...]
|
|
51
|
+
tools: tuple[Tool, ...]
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
prompt: str | None = None,
|
|
56
|
+
*,
|
|
57
|
+
messages: Sequence[Message] | None = None,
|
|
58
|
+
tools: Sequence[Tool] | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
if prompt is not None and messages is not None:
|
|
61
|
+
raise ValueError("provide either prompt or messages, not both")
|
|
62
|
+
if messages is None:
|
|
63
|
+
if prompt is None or not prompt.strip():
|
|
64
|
+
raise ValueError("prompt must not be empty")
|
|
65
|
+
messages = (Message(role="user", content=prompt),)
|
|
66
|
+
if not messages:
|
|
67
|
+
raise ValueError("messages must not be empty")
|
|
68
|
+
object.__setattr__(self, "messages", tuple(messages))
|
|
69
|
+
object.__setattr__(self, "tools", tuple(tools or ()))
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def prompt(self) -> str:
|
|
73
|
+
"""Compatibility alias for the newest message content."""
|
|
74
|
+
return self.messages[-1].content
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class GenerationResponse:
|
|
79
|
+
"""The normalized output returned by a model provider."""
|
|
80
|
+
|
|
81
|
+
text: str
|
|
82
|
+
model: str
|
|
83
|
+
tool_calls: tuple[ToolCall, ...] = ()
|
|
84
|
+
|
|
85
|
+
def __post_init__(self) -> None:
|
|
86
|
+
if isinstance(self.tool_calls, (list, tuple)):
|
|
87
|
+
object.__setattr__(self, "tool_calls", tuple(self.tool_calls))
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Concrete provider adapters and their shared abstraction."""
|
|
2
|
+
|
|
3
|
+
from .anthropic import AnthropicProvider
|
|
4
|
+
from .base import ModelProvider
|
|
5
|
+
from .gemini import GeminiProvider
|
|
6
|
+
from .openai import OpenAIProvider
|
|
7
|
+
|
|
8
|
+
__all__ = ["AnthropicProvider", "GeminiProvider", "ModelProvider", "OpenAIProvider"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Anthropic adapter."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from evidor.models import GenerationRequest, GenerationResponse, ToolCall
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AnthropicProvider:
|
|
9
|
+
def __init__(self, model: str, api_key: str | None = None, max_tokens: int = 1024) -> None:
|
|
10
|
+
self._model = model
|
|
11
|
+
self._api_key = api_key
|
|
12
|
+
self._max_tokens = max_tokens
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def model(self) -> str:
|
|
16
|
+
return self._model
|
|
17
|
+
|
|
18
|
+
def with_model(self, model: str) -> "AnthropicProvider":
|
|
19
|
+
return AnthropicProvider(model=model, api_key=self._api_key, max_tokens=self._max_tokens)
|
|
20
|
+
|
|
21
|
+
def generate(self, request: GenerationRequest) -> GenerationResponse:
|
|
22
|
+
try:
|
|
23
|
+
from anthropic import Anthropic
|
|
24
|
+
except ImportError as error:
|
|
25
|
+
raise ImportError("Install Anthropic support with: pip install 'evidor[anthropic]'") from error
|
|
26
|
+
|
|
27
|
+
client = Anthropic(api_key=self._api_key)
|
|
28
|
+
system_prompt = "\n\n".join(message.content for message in request.messages if message.role == "system")
|
|
29
|
+
|
|
30
|
+
formatted_messages: list[dict[str, Any]] = []
|
|
31
|
+
for message in request.messages:
|
|
32
|
+
if message.role == "system":
|
|
33
|
+
continue
|
|
34
|
+
if message.role == "tool":
|
|
35
|
+
formatted_messages.append({
|
|
36
|
+
"role": "user",
|
|
37
|
+
"content": [
|
|
38
|
+
{
|
|
39
|
+
"type": "tool_result",
|
|
40
|
+
"tool_use_id": message.tool_call_id,
|
|
41
|
+
"content": message.content,
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
})
|
|
45
|
+
elif message.role == "assistant" and message.tool_calls:
|
|
46
|
+
content_blocks: list[dict[str, Any]] = []
|
|
47
|
+
if message.content:
|
|
48
|
+
content_blocks.append({"type": "text", "text": message.content})
|
|
49
|
+
for tc in message.tool_calls:
|
|
50
|
+
content_blocks.append({
|
|
51
|
+
"type": "tool_use",
|
|
52
|
+
"id": tc.id,
|
|
53
|
+
"name": tc.name,
|
|
54
|
+
"input": tc.arguments,
|
|
55
|
+
})
|
|
56
|
+
formatted_messages.append({"role": "assistant", "content": content_blocks})
|
|
57
|
+
else:
|
|
58
|
+
formatted_messages.append({"role": message.role, "content": message.content})
|
|
59
|
+
|
|
60
|
+
kwargs: dict[str, Any] = {
|
|
61
|
+
"model": self._model,
|
|
62
|
+
"max_tokens": self._max_tokens,
|
|
63
|
+
"system": system_prompt or None,
|
|
64
|
+
"messages": formatted_messages,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if request.tools:
|
|
68
|
+
kwargs["tools"] = [
|
|
69
|
+
{
|
|
70
|
+
"name": t.name,
|
|
71
|
+
"description": t.description,
|
|
72
|
+
"input_schema": t.parameters,
|
|
73
|
+
}
|
|
74
|
+
for t in request.tools
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
response_msg = client.messages.create(**kwargs)
|
|
78
|
+
|
|
79
|
+
text_parts: list[str] = []
|
|
80
|
+
tool_calls: list[ToolCall] = []
|
|
81
|
+
|
|
82
|
+
for block in getattr(response_msg, "content", []):
|
|
83
|
+
block_type = getattr(block, "type", None)
|
|
84
|
+
if block_type == "text":
|
|
85
|
+
text_parts.append(getattr(block, "text", ""))
|
|
86
|
+
elif block_type == "tool_use":
|
|
87
|
+
tool_calls.append(
|
|
88
|
+
ToolCall(
|
|
89
|
+
id=getattr(block, "id", f"call_{len(tool_calls)+1}"),
|
|
90
|
+
name=getattr(block, "name", ""),
|
|
91
|
+
arguments=getattr(block, "input", {}),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return GenerationResponse(
|
|
96
|
+
text="".join(text_parts),
|
|
97
|
+
model=self._model,
|
|
98
|
+
tool_calls=tuple(tool_calls),
|
|
99
|
+
)
|
evidor/providers/base.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""The port implemented by every model-provider adapter."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
from evidor.models import GenerationRequest, GenerationResponse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class ModelProvider(Protocol):
|
|
10
|
+
"""Produces one normalized response from one normalized request."""
|
|
11
|
+
|
|
12
|
+
def generate(self, request: GenerationRequest) -> GenerationResponse:
|
|
13
|
+
"""Generate a response for the request."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
def with_model(self, model: str) -> "ModelProvider":
|
|
17
|
+
"""Return a copy of this provider configured with a different model."""
|
|
18
|
+
...
|