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.
Files changed (57) hide show
  1. actant/__init__.py +29 -0
  2. actant/agents.py +64 -0
  3. actant/core.py +15 -0
  4. actant/inbox.py +21 -0
  5. actant/llm/__init__.py +14 -0
  6. actant/llm/base.py +23 -0
  7. actant/llm/errors.py +7 -0
  8. actant/llm/messages.py +156 -0
  9. actant/llm/providers/__init__.py +45 -0
  10. actant/llm/providers/_shared.py +112 -0
  11. actant/llm/providers/anthropic.py +380 -0
  12. actant/llm/providers/fake.py +60 -0
  13. actant/llm/providers/gemini.py +330 -0
  14. actant/llm/providers/openai.py +469 -0
  15. actant/llm/providers/qwen.py +188 -0
  16. actant/llm/rate_limit.py +150 -0
  17. actant/llm/routing.py +76 -0
  18. actant/py.typed +1 -0
  19. actant/runtime/__init__.py +52 -0
  20. actant/runtime/completion.py +31 -0
  21. actant/runtime/coordinator.py +245 -0
  22. actant/runtime/events/__init__.py +13 -0
  23. actant/runtime/events/lifecycle.py +197 -0
  24. actant/runtime/events/publisher.py +21 -0
  25. actant/runtime/events/streaming.py +84 -0
  26. actant/runtime/exceptions.py +46 -0
  27. actant/runtime/interfaces/__init__.py +23 -0
  28. actant/runtime/interfaces/session.py +31 -0
  29. actant/runtime/interfaces/stores.py +158 -0
  30. actant/runtime/runtime.py +90 -0
  31. actant/runtime/session.py +278 -0
  32. actant/runtime/stores/__init__.py +48 -0
  33. actant/runtime/stores/in_memory.py +299 -0
  34. actant/runtime/stores/postgres/__init__.py +44 -0
  35. actant/runtime/stores/postgres/conversion.py +158 -0
  36. actant/runtime/stores/postgres/models.py +148 -0
  37. actant/runtime/stores/postgres/stores.py +505 -0
  38. actant/runtime/temporal/__init__.py +22 -0
  39. actant/runtime/temporal/activities.py +718 -0
  40. actant/runtime/temporal/client.py +357 -0
  41. actant/runtime/temporal/types.py +298 -0
  42. actant/runtime/temporal/worker.py +63 -0
  43. actant/runtime/temporal/workflow.py +378 -0
  44. actant/runtime/types/__init__.py +23 -0
  45. actant/runtime/types/context.py +18 -0
  46. actant/runtime/types/session.py +43 -0
  47. actant/runtime/types/threads.py +71 -0
  48. actant/tools/__init__.py +40 -0
  49. actant/tools/admission.py +138 -0
  50. actant/tools/base.py +106 -0
  51. actant/tools/calls.py +41 -0
  52. actant/tools/registry.py +31 -0
  53. actant/tools/task.py +298 -0
  54. actant-0.1.0.dist-info/METADATA +383 -0
  55. actant-0.1.0.dist-info/RECORD +57 -0
  56. actant-0.1.0.dist-info/WHEEL +4 -0
  57. 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
@@ -0,0 +1,7 @@
1
+ """LLM error types."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class StreamCancelled(Exception):
7
+ """Raised when a stream listener requests cancellation mid-generation."""
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