actant 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- actant/__init__.py +29 -0
- actant/agents.py +64 -0
- actant/core.py +15 -0
- actant/inbox.py +21 -0
- actant/llm/__init__.py +14 -0
- actant/llm/base.py +23 -0
- actant/llm/errors.py +7 -0
- actant/llm/messages.py +156 -0
- actant/llm/providers/__init__.py +45 -0
- actant/llm/providers/_shared.py +112 -0
- actant/llm/providers/anthropic.py +380 -0
- actant/llm/providers/fake.py +60 -0
- actant/llm/providers/gemini.py +330 -0
- actant/llm/providers/openai.py +469 -0
- actant/llm/providers/qwen.py +188 -0
- actant/llm/rate_limit.py +150 -0
- actant/llm/routing.py +76 -0
- actant/py.typed +1 -0
- actant/runtime/__init__.py +52 -0
- actant/runtime/completion.py +31 -0
- actant/runtime/coordinator.py +245 -0
- actant/runtime/events/__init__.py +13 -0
- actant/runtime/events/lifecycle.py +197 -0
- actant/runtime/events/publisher.py +21 -0
- actant/runtime/events/streaming.py +84 -0
- actant/runtime/exceptions.py +46 -0
- actant/runtime/interfaces/__init__.py +23 -0
- actant/runtime/interfaces/session.py +31 -0
- actant/runtime/interfaces/stores.py +158 -0
- actant/runtime/runtime.py +90 -0
- actant/runtime/session.py +278 -0
- actant/runtime/stores/__init__.py +48 -0
- actant/runtime/stores/in_memory.py +299 -0
- actant/runtime/stores/postgres/__init__.py +44 -0
- actant/runtime/stores/postgres/conversion.py +158 -0
- actant/runtime/stores/postgres/models.py +148 -0
- actant/runtime/stores/postgres/stores.py +505 -0
- actant/runtime/temporal/__init__.py +22 -0
- actant/runtime/temporal/activities.py +718 -0
- actant/runtime/temporal/client.py +357 -0
- actant/runtime/temporal/types.py +298 -0
- actant/runtime/temporal/worker.py +63 -0
- actant/runtime/temporal/workflow.py +378 -0
- actant/runtime/types/__init__.py +23 -0
- actant/runtime/types/context.py +18 -0
- actant/runtime/types/session.py +43 -0
- actant/runtime/types/threads.py +71 -0
- actant/tools/__init__.py +40 -0
- actant/tools/admission.py +138 -0
- actant/tools/base.py +106 -0
- actant/tools/calls.py +41 -0
- actant/tools/registry.py +31 -0
- actant/tools/task.py +298 -0
- actant-0.1.0.dist-info/METADATA +383 -0
- actant-0.1.0.dist-info/RECORD +57 -0
- actant-0.1.0.dist-info/WHEEL +4 -0
- actant-0.1.0.dist-info/licenses/LICENSE +21 -0
actant/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Actant runtime package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from actant.agents import Agent, AgentDefinition, ContextPolicy, ModelConfig
|
|
9
|
+
from actant.runtime import AgentRuntime
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Agent",
|
|
13
|
+
"AgentDefinition",
|
|
14
|
+
"AgentRuntime",
|
|
15
|
+
"ContextPolicy",
|
|
16
|
+
"ModelConfig",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def __getattr__(name: str) -> Any:
|
|
21
|
+
if name in {"Agent", "AgentDefinition", "ContextPolicy", "ModelConfig"}:
|
|
22
|
+
from actant import agents
|
|
23
|
+
|
|
24
|
+
return getattr(agents, name)
|
|
25
|
+
if name == "AgentRuntime":
|
|
26
|
+
from actant import runtime
|
|
27
|
+
|
|
28
|
+
return runtime.AgentRuntime
|
|
29
|
+
raise AttributeError(f"module 'actant' has no attribute {name!r}")
|
actant/agents.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Agent configuration models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from actant.llm.base import LLMClient
|
|
10
|
+
from actant.llm.messages import Message
|
|
11
|
+
from actant.tools.registry import ToolRegistry
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from actant.runtime.events.streaming import StreamListener
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ModelConfig:
|
|
19
|
+
provider: str
|
|
20
|
+
model: str
|
|
21
|
+
temperature: float = 0.0
|
|
22
|
+
max_tokens: int | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Agent:
|
|
27
|
+
id: str
|
|
28
|
+
name: str
|
|
29
|
+
persona: str
|
|
30
|
+
persona_version: str
|
|
31
|
+
model: ModelConfig
|
|
32
|
+
tool_allowlist: set[str] = field(default_factory=set)
|
|
33
|
+
max_turns_per_thread: int = 25
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ContextPolicy:
|
|
38
|
+
max_input_tokens: int | None = None
|
|
39
|
+
reserve_output_tokens: int = 4096
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class AgentDefinition:
|
|
44
|
+
id: str
|
|
45
|
+
name: str
|
|
46
|
+
persona: str
|
|
47
|
+
llm: LLMClient
|
|
48
|
+
tools: ToolRegistry
|
|
49
|
+
tool_allowlist: set[str] = field(default_factory=set)
|
|
50
|
+
context_policy: ContextPolicy = field(default_factory=ContextPolicy)
|
|
51
|
+
persona_version: str = "v1"
|
|
52
|
+
max_turns_per_thread: int = 25
|
|
53
|
+
|
|
54
|
+
async def complete(
|
|
55
|
+
self,
|
|
56
|
+
messages: Sequence[Message],
|
|
57
|
+
listener: "StreamListener | None" = None,
|
|
58
|
+
) -> Message:
|
|
59
|
+
return await self.llm.complete(
|
|
60
|
+
self.persona,
|
|
61
|
+
list(messages),
|
|
62
|
+
self.tools.schemas_for(self.tool_allowlist),
|
|
63
|
+
listener,
|
|
64
|
+
)
|
actant/core.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Core utilities and structural types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import TypeAlias
|
|
7
|
+
|
|
8
|
+
JSONValue: TypeAlias = (
|
|
9
|
+
str | int | float | bool | None | list["JSONValue"] | dict[str, "JSONValue"]
|
|
10
|
+
)
|
|
11
|
+
JSONObject: TypeAlias = dict[str, JSONValue]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def new_id(prefix: str) -> str:
|
|
15
|
+
return f"{prefix}_{uuid.uuid4().hex}"
|
actant/inbox.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Durable inbox message models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
|
|
8
|
+
from actant.core import JSONObject
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class InboxMessage:
|
|
13
|
+
id: str
|
|
14
|
+
agent_id: str
|
|
15
|
+
thread_id: str
|
|
16
|
+
payload: JSONObject
|
|
17
|
+
source: str = "user"
|
|
18
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
19
|
+
visible_at: datetime | None = None
|
|
20
|
+
correlation_id: str | None = None
|
|
21
|
+
environment_id: str | None = None
|
actant/llm/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""LLM provider interfaces."""
|
|
2
|
+
|
|
3
|
+
from actant.llm.base import LLMClient
|
|
4
|
+
from actant.llm.messages import Message, ToolCall, ToolCallFunction
|
|
5
|
+
from actant.llm.routing import llm_for_model, provider_for_model
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"LLMClient",
|
|
9
|
+
"Message",
|
|
10
|
+
"ToolCall",
|
|
11
|
+
"ToolCallFunction",
|
|
12
|
+
"llm_for_model",
|
|
13
|
+
"provider_for_model",
|
|
14
|
+
]
|
actant/llm/base.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""LLM client protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import TYPE_CHECKING, Protocol
|
|
7
|
+
|
|
8
|
+
from actant.llm.messages import Message
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from actant.runtime.events.streaming import StreamListener
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LLMClient(Protocol):
|
|
15
|
+
model_id: str
|
|
16
|
+
|
|
17
|
+
async def complete(
|
|
18
|
+
self,
|
|
19
|
+
system: str,
|
|
20
|
+
messages: Sequence[Message],
|
|
21
|
+
tools: list[dict],
|
|
22
|
+
listener: "StreamListener | None" = None,
|
|
23
|
+
) -> Message: ...
|
actant/llm/errors.py
ADDED
actant/llm/messages.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Provider-neutral message and streaming models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Literal, cast
|
|
9
|
+
|
|
10
|
+
Role = Literal["system", "user", "assistant", "tool"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ToolCallFunction:
|
|
15
|
+
name: str
|
|
16
|
+
arguments: str
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def from_raw(cls, value: "ToolCallFunction | dict[str, object]") -> "ToolCallFunction":
|
|
20
|
+
if isinstance(value, cls):
|
|
21
|
+
return cls(name=value.name, arguments=value.arguments)
|
|
22
|
+
if not isinstance(value, dict):
|
|
23
|
+
raise TypeError(f"Expected ToolCallFunction or dict, got {type(value).__name__}")
|
|
24
|
+
|
|
25
|
+
raw_arguments = value.get("arguments", "")
|
|
26
|
+
if raw_arguments is None:
|
|
27
|
+
arguments = ""
|
|
28
|
+
elif isinstance(raw_arguments, str):
|
|
29
|
+
arguments = raw_arguments
|
|
30
|
+
else:
|
|
31
|
+
arguments = json.dumps(raw_arguments)
|
|
32
|
+
|
|
33
|
+
raw_name = value.get("name", "")
|
|
34
|
+
name = "" if raw_name is None else str(raw_name)
|
|
35
|
+
return cls(name=name, arguments=arguments)
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, object]:
|
|
38
|
+
return {"name": self.name, "arguments": self.arguments}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ToolCall:
|
|
43
|
+
id: str
|
|
44
|
+
function: ToolCallFunction
|
|
45
|
+
type: str = "function"
|
|
46
|
+
thought_signature: str | None = None
|
|
47
|
+
extra_content: dict[str, object] = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_raw(cls, value: "ToolCall | dict[str, object]") -> "ToolCall":
|
|
51
|
+
if isinstance(value, cls):
|
|
52
|
+
return cls(
|
|
53
|
+
id=value.id,
|
|
54
|
+
function=ToolCallFunction.from_raw(value.function),
|
|
55
|
+
type=value.type,
|
|
56
|
+
thought_signature=value.thought_signature,
|
|
57
|
+
extra_content=deepcopy(value.extra_content),
|
|
58
|
+
)
|
|
59
|
+
if not isinstance(value, dict):
|
|
60
|
+
raise TypeError(f"Expected ToolCall or dict, got {type(value).__name__}")
|
|
61
|
+
|
|
62
|
+
raw_id = value.get("id", "")
|
|
63
|
+
raw_type = value.get("type", "function")
|
|
64
|
+
raw_extra = value.get("extra_content", {})
|
|
65
|
+
raw_signature = value.get("thought_signature")
|
|
66
|
+
return cls(
|
|
67
|
+
id="" if raw_id is None else str(raw_id),
|
|
68
|
+
function=ToolCallFunction.from_raw(cast(dict[str, object], value.get("function", {}))),
|
|
69
|
+
type="function" if raw_type is None else str(raw_type),
|
|
70
|
+
thought_signature=str(raw_signature) if raw_signature is not None else None,
|
|
71
|
+
extra_content=deepcopy(raw_extra) if isinstance(raw_extra, dict) else {},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def to_dict(self) -> dict[str, object]:
|
|
75
|
+
data: dict[str, object] = {
|
|
76
|
+
"id": self.id,
|
|
77
|
+
"function": self.function.to_dict(),
|
|
78
|
+
}
|
|
79
|
+
if self.type != "function":
|
|
80
|
+
data["type"] = self.type
|
|
81
|
+
if self.thought_signature is not None:
|
|
82
|
+
data["thought_signature"] = self.thought_signature
|
|
83
|
+
if self.extra_content:
|
|
84
|
+
data["extra_content"] = deepcopy(self.extra_content)
|
|
85
|
+
return data
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Message:
|
|
90
|
+
role: Role
|
|
91
|
+
content: str | list[dict[str, object]] | None = None
|
|
92
|
+
tool_calls: list[ToolCall] | None = None
|
|
93
|
+
tool_call_id: str | None = None
|
|
94
|
+
name: str | None = None
|
|
95
|
+
thought_summary: str | None = None
|
|
96
|
+
thinking_signature: str | None = None
|
|
97
|
+
reasoning_items: list[object] | None = None
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def from_raw(cls, value: "Message | dict[str, object]") -> "Message":
|
|
101
|
+
if isinstance(value, cls):
|
|
102
|
+
return cls(
|
|
103
|
+
role=value.role,
|
|
104
|
+
content=deepcopy(value.content),
|
|
105
|
+
tool_calls=(
|
|
106
|
+
[ToolCall.from_raw(tc) for tc in value.tool_calls]
|
|
107
|
+
if value.tool_calls is not None
|
|
108
|
+
else None
|
|
109
|
+
),
|
|
110
|
+
tool_call_id=value.tool_call_id,
|
|
111
|
+
name=value.name,
|
|
112
|
+
thought_summary=value.thought_summary,
|
|
113
|
+
thinking_signature=value.thinking_signature,
|
|
114
|
+
reasoning_items=deepcopy(value.reasoning_items),
|
|
115
|
+
)
|
|
116
|
+
if not isinstance(value, dict):
|
|
117
|
+
raise TypeError(f"Expected Message or dict, got {type(value).__name__}")
|
|
118
|
+
|
|
119
|
+
raw_tool_calls = value.get("tool_calls")
|
|
120
|
+
content = value.get("content", "")
|
|
121
|
+
raw_reasoning_items = value.get("reasoning_items")
|
|
122
|
+
return cls(
|
|
123
|
+
role=cast(Role, value.get("role", "user")),
|
|
124
|
+
content=deepcopy(content) if isinstance(content, list) else str(content),
|
|
125
|
+
tool_calls=(
|
|
126
|
+
[ToolCall.from_raw(tc) for tc in raw_tool_calls]
|
|
127
|
+
if isinstance(raw_tool_calls, list)
|
|
128
|
+
else None
|
|
129
|
+
),
|
|
130
|
+
tool_call_id=cast(str | None, value.get("tool_call_id")),
|
|
131
|
+
name=cast(str | None, value.get("name")),
|
|
132
|
+
thought_summary=cast(str | None, value.get("thought_summary")),
|
|
133
|
+
thinking_signature=cast(str | None, value.get("thinking_signature")),
|
|
134
|
+
reasoning_items=(
|
|
135
|
+
deepcopy(raw_reasoning_items) if isinstance(raw_reasoning_items, list) else None
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def to_dict(self) -> dict[str, object]:
|
|
140
|
+
data: dict[str, object] = {
|
|
141
|
+
"role": self.role,
|
|
142
|
+
"content": deepcopy(self.content),
|
|
143
|
+
}
|
|
144
|
+
if self.tool_calls is not None:
|
|
145
|
+
data["tool_calls"] = [tc.to_dict() for tc in self.tool_calls]
|
|
146
|
+
if self.tool_call_id is not None:
|
|
147
|
+
data["tool_call_id"] = self.tool_call_id
|
|
148
|
+
if self.name is not None:
|
|
149
|
+
data["name"] = self.name
|
|
150
|
+
if self.thought_summary is not None:
|
|
151
|
+
data["thought_summary"] = self.thought_summary
|
|
152
|
+
if self.thinking_signature is not None:
|
|
153
|
+
data["thinking_signature"] = self.thinking_signature
|
|
154
|
+
if self.reasoning_items is not None:
|
|
155
|
+
data["reasoning_items"] = deepcopy(self.reasoning_items)
|
|
156
|
+
return data
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""LLM provider implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from actant.llm.providers.anthropic import AnthropicProvider
|
|
9
|
+
from actant.llm.providers.fake import FakeLLM, FakeResponse
|
|
10
|
+
from actant.llm.providers.gemini import GeminiProvider
|
|
11
|
+
from actant.llm.providers.openai import OpenAIProvider
|
|
12
|
+
from actant.llm.providers.qwen import QwenProvider
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AnthropicProvider",
|
|
16
|
+
"FakeLLM",
|
|
17
|
+
"FakeResponse",
|
|
18
|
+
"GeminiProvider",
|
|
19
|
+
"OpenAIProvider",
|
|
20
|
+
"QwenProvider",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def __getattr__(name: str) -> Any:
|
|
25
|
+
if name == "AnthropicProvider":
|
|
26
|
+
from actant.llm.providers.anthropic import AnthropicProvider
|
|
27
|
+
|
|
28
|
+
return AnthropicProvider
|
|
29
|
+
if name in {"FakeLLM", "FakeResponse"}:
|
|
30
|
+
from actant.llm.providers import fake
|
|
31
|
+
|
|
32
|
+
return getattr(fake, name)
|
|
33
|
+
if name == "GeminiProvider":
|
|
34
|
+
from actant.llm.providers.gemini import GeminiProvider
|
|
35
|
+
|
|
36
|
+
return GeminiProvider
|
|
37
|
+
if name == "OpenAIProvider":
|
|
38
|
+
from actant.llm.providers.openai import OpenAIProvider
|
|
39
|
+
|
|
40
|
+
return OpenAIProvider
|
|
41
|
+
if name == "QwenProvider":
|
|
42
|
+
from actant.llm.providers.qwen import QwenProvider
|
|
43
|
+
|
|
44
|
+
return QwenProvider
|
|
45
|
+
raise AttributeError(f"module 'actant.llm.providers' has no attribute {name!r}")
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Shared helpers used by two or more provider adapters.
|
|
2
|
+
|
|
3
|
+
Single-provider helpers live in their owning provider module:
|
|
4
|
+
- ``REASONING_EFFORT``, ``content_to_openai_user_parts`` → ``openai.py``
|
|
5
|
+
- ``dereference_schema``, ``strip_unsupported_schema_keys`` → ``gemini.py``
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
|
|
14
|
+
from actant.llm.messages import Message, ToolCall
|
|
15
|
+
|
|
16
|
+
ToolSchema = dict[str, object]
|
|
17
|
+
ContentBlock = dict[str, object]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def env_api_key(name: str, explicit: str | None = None) -> str:
|
|
21
|
+
key = explicit or os.environ.get(name)
|
|
22
|
+
if not key:
|
|
23
|
+
raise RuntimeError(f"{name} is required for this provider adapter.")
|
|
24
|
+
return key
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize_json_schema(schema: object) -> object:
|
|
28
|
+
if not isinstance(schema, dict):
|
|
29
|
+
return schema
|
|
30
|
+
|
|
31
|
+
result: dict[str, object] = {}
|
|
32
|
+
for key, value in schema.items():
|
|
33
|
+
if key == "prefixItems":
|
|
34
|
+
if value and isinstance(value, list):
|
|
35
|
+
result["items"] = normalize_json_schema(value[0])
|
|
36
|
+
continue
|
|
37
|
+
if key == "$defs" and isinstance(value, dict):
|
|
38
|
+
result[key] = {k: normalize_json_schema(v) for k, v in value.items()}
|
|
39
|
+
elif isinstance(value, dict):
|
|
40
|
+
result[key] = normalize_json_schema(value)
|
|
41
|
+
elif isinstance(value, list):
|
|
42
|
+
result[key] = [normalize_json_schema(item) for item in value]
|
|
43
|
+
else:
|
|
44
|
+
result[key] = value
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def convert_image_source(source: Mapping[str, object]) -> ContentBlock | None:
|
|
49
|
+
if source.get("type") == "base64":
|
|
50
|
+
return {
|
|
51
|
+
"type": "input_image",
|
|
52
|
+
"image_url": f"data:{source['media_type']};base64,{source['data']}",
|
|
53
|
+
}
|
|
54
|
+
if source.get("type") == "url":
|
|
55
|
+
return {"type": "input_image", "image_url": source["url"]}
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def split_tool_content(
|
|
60
|
+
content: str | list[ContentBlock] | None,
|
|
61
|
+
) -> tuple[str, list[ContentBlock]]:
|
|
62
|
+
if content is None:
|
|
63
|
+
return "", []
|
|
64
|
+
if isinstance(content, str):
|
|
65
|
+
return content, []
|
|
66
|
+
|
|
67
|
+
text_parts: list[str] = []
|
|
68
|
+
image_parts: list[ContentBlock] = []
|
|
69
|
+
for block in content:
|
|
70
|
+
if not isinstance(block, dict):
|
|
71
|
+
text_parts.append(str(block))
|
|
72
|
+
elif block.get("type") == "text":
|
|
73
|
+
text_parts.append(str(block.get("text", "")))
|
|
74
|
+
elif block.get("type") == "image":
|
|
75
|
+
source = block.get("source")
|
|
76
|
+
image = convert_image_source(source) if isinstance(source, Mapping) else None
|
|
77
|
+
if image:
|
|
78
|
+
image_parts.append(image)
|
|
79
|
+
return "\n".join(text_parts) if text_parts else "OK", image_parts
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def sanitize_tool_messages(
|
|
83
|
+
messages: Sequence[Message | dict[str, object]],
|
|
84
|
+
) -> list[Message]:
|
|
85
|
+
sanitized: list[Message] = []
|
|
86
|
+
pending_ids: list[str] = []
|
|
87
|
+
|
|
88
|
+
for raw_message in messages:
|
|
89
|
+
message = Message.from_raw(raw_message)
|
|
90
|
+
if message.role == "assistant" and message.tool_calls is not None:
|
|
91
|
+
normalized_tool_calls: list[ToolCall] = []
|
|
92
|
+
for raw_tool_call in message.tool_calls:
|
|
93
|
+
tool_call = ToolCall.from_raw(raw_tool_call)
|
|
94
|
+
if not tool_call.id:
|
|
95
|
+
tool_call.id = f"call_{uuid.uuid4().hex}"
|
|
96
|
+
pending_ids.append(tool_call.id)
|
|
97
|
+
normalized_tool_calls.append(tool_call)
|
|
98
|
+
message.tool_calls = normalized_tool_calls
|
|
99
|
+
elif message.role == "tool":
|
|
100
|
+
tool_call_id = message.tool_call_id or ""
|
|
101
|
+
if tool_call_id:
|
|
102
|
+
if pending_ids and pending_ids[0] == tool_call_id:
|
|
103
|
+
pending_ids.pop(0)
|
|
104
|
+
elif tool_call_id in pending_ids:
|
|
105
|
+
pending_ids.remove(tool_call_id)
|
|
106
|
+
elif pending_ids:
|
|
107
|
+
message.tool_call_id = pending_ids.pop(0)
|
|
108
|
+
else:
|
|
109
|
+
message.tool_call_id = f"call_{uuid.uuid4().hex}"
|
|
110
|
+
sanitized.append(message)
|
|
111
|
+
|
|
112
|
+
return sanitized
|