rusticai-llm-agent 1.0.0__tar.gz
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.
- rusticai_llm_agent-1.0.0/PKG-INFO +36 -0
- rusticai_llm_agent-1.0.0/README.md +18 -0
- rusticai_llm_agent-1.0.0/pyproject.toml +77 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/__init__.py +32 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/llm_agent.py +71 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/llm_agent_conf.py +231 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/llm_agent_helper.py +213 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/llm_agent_utils.py +167 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/memories/__init__.py +11 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/memories/guild_state_memories_store.py +26 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/memories/history_memories_store.py +94 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/memories/memories_store.py +81 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/memories/queue_memories_store.py +28 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/memories/state_memories_store.py +48 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/model.py +16 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/plugins/__init__.py +12 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/plugins/llm_call_wrapper.py +62 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/plugins/prompt_generators.py +30 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/plugins/request_preprocessor.py +42 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/plugins/response_postprocessor.py +47 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/tool_enabled_agent.py +0 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/tools/__init__.py +13 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/tools/tools_manager_plugin.py +173 -0
- rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/tools/tools_provider.py +31 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rusticai-llm-agent
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A complete set of LLM agents
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Author: Dragonscale Industries Inc.
|
|
7
|
+
Author-email: dev@dragonscale.ai
|
|
8
|
+
Requires-Python: >=3.12,<3.13
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Provides-Extra: test
|
|
12
|
+
Requires-Dist: openai (>=1.107.0,<2.0.0)
|
|
13
|
+
Requires-Dist: rusticai-core (>=1.0.0,<1.1.0)
|
|
14
|
+
Project-URL: Homepage, https://www.rustic.ai/
|
|
15
|
+
Project-URL: Repository, https://github.com/rustic-ai/rustic-ai
|
|
16
|
+
Project-URL: Rustic AI Core, https://pypi.org/project/rusticai-core/
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# Rustic AI LLMAgent
|
|
20
|
+
|
|
21
|
+
This module introduces a pluggable LLMAgent based on common patterns observed while using of language models within [Rustic AI](https://www.rustic.ai/) guilds.
|
|
22
|
+
The implementation leverages the Rustic AI's LLM dependency injection to allow for custom implementations of the LLM.
|
|
23
|
+
|
|
24
|
+
## Installing
|
|
25
|
+
|
|
26
|
+
```shell
|
|
27
|
+
pip install rusticai-llm-agent rusticai-litellm
|
|
28
|
+
```
|
|
29
|
+
**Note:** It depends on [rusticai-core](https://pypi.org/project/rusticai-core/)
|
|
30
|
+
|
|
31
|
+
## Building from Source
|
|
32
|
+
|
|
33
|
+
```shell
|
|
34
|
+
poetry install --with dev
|
|
35
|
+
poetry build
|
|
36
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Rustic AI LLMAgent
|
|
2
|
+
|
|
3
|
+
This module introduces a pluggable LLMAgent based on common patterns observed while using of language models within [Rustic AI](https://www.rustic.ai/) guilds.
|
|
4
|
+
The implementation leverages the Rustic AI's LLM dependency injection to allow for custom implementations of the LLM.
|
|
5
|
+
|
|
6
|
+
## Installing
|
|
7
|
+
|
|
8
|
+
```shell
|
|
9
|
+
pip install rusticai-llm-agent rusticai-litellm
|
|
10
|
+
```
|
|
11
|
+
**Note:** It depends on [rusticai-core](https://pypi.org/project/rusticai-core/)
|
|
12
|
+
|
|
13
|
+
## Building from Source
|
|
14
|
+
|
|
15
|
+
```shell
|
|
16
|
+
poetry install --with dev
|
|
17
|
+
poetry build
|
|
18
|
+
```
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["poetry-core"]
|
|
3
|
+
build-backend = "poetry.core.masonry.api"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "rusticai-llm-agent"
|
|
8
|
+
version = "1.0.0"
|
|
9
|
+
description = "A complete set of LLM agents"
|
|
10
|
+
authors = [{name = "Dragonscale Industries Inc.", email = "dev@dragonscale.ai"}]
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.12,<3.13"
|
|
14
|
+
dynamic = ["dependencies"]
|
|
15
|
+
urls = { Homepage = "https://www.rustic.ai/", Repository = "https://github.com/rustic-ai/rustic-ai", "Rustic AI Core" = "https://pypi.org/project/rusticai-core/" }
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
[tool.poetry]
|
|
19
|
+
packages = [{ include = "rustic_ai", from = "src" }]
|
|
20
|
+
|
|
21
|
+
[tool.poetry.dependencies]
|
|
22
|
+
rusticai-core = { version = "1.0.0"}
|
|
23
|
+
openai = "^1.107.0"
|
|
24
|
+
|
|
25
|
+
[tool.poetry-monorepo.deps]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
[tool.poetry.group.dev]
|
|
29
|
+
optional = true
|
|
30
|
+
|
|
31
|
+
[tool.poetry.group.dev.dependencies]
|
|
32
|
+
pytest = "^8.3.4"
|
|
33
|
+
black = { version = "^24.2.0", extras = ["jupyter"] }
|
|
34
|
+
flake8 = "^7.1.2"
|
|
35
|
+
tox = "^4.24.1"
|
|
36
|
+
isort = "^6.0.0"
|
|
37
|
+
mypy = "^1.15.0"
|
|
38
|
+
flaky = "^3.8.1"
|
|
39
|
+
pytest-asyncio = "^0.26.0"
|
|
40
|
+
rusticai-testing = { version = "1.0.0"}
|
|
41
|
+
rusticai-litellm = { version = "1.0.0"}
|
|
42
|
+
|
|
43
|
+
[tool.poetry.extras]
|
|
44
|
+
test = ["pytest", "pytest-asyncio", "rusticai-testing","rusticai-litellm"]
|
|
45
|
+
|
|
46
|
+
[tool.black]
|
|
47
|
+
line-length = 120
|
|
48
|
+
target-version = ['py312']
|
|
49
|
+
include = '\.pyi?$'
|
|
50
|
+
exclude = '''
|
|
51
|
+
/(
|
|
52
|
+
\.git
|
|
53
|
+
| \.mypy_cache
|
|
54
|
+
| \.tox
|
|
55
|
+
| \.venv
|
|
56
|
+
| _build
|
|
57
|
+
| buck-out
|
|
58
|
+
| build
|
|
59
|
+
| dist
|
|
60
|
+
)/
|
|
61
|
+
'''
|
|
62
|
+
|
|
63
|
+
[tool.mypy]
|
|
64
|
+
python_version = "3.12"
|
|
65
|
+
ignore_missing_imports = true
|
|
66
|
+
check_untyped_defs = true
|
|
67
|
+
plugins = "pydantic.mypy"
|
|
68
|
+
explicit_package_bases = true
|
|
69
|
+
|
|
70
|
+
[tool.isort]
|
|
71
|
+
profile = "black"
|
|
72
|
+
force_sort_within_sections = true # For the sorting order issues
|
|
73
|
+
lines_between_sections = 1 # For the newline issue
|
|
74
|
+
known_third_party = ["pydantic"]
|
|
75
|
+
known_first_party = ["rustic_ai.core", "rustic_ai.llm_agent"]
|
|
76
|
+
known_local_folder = ["rustic_ai.testing"]
|
|
77
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from .llm_agent import LLMAgent
|
|
2
|
+
from .llm_agent_conf import LLMAgentConfig
|
|
3
|
+
from .llm_agent_utils import LLMAgentUtils
|
|
4
|
+
from .memories import (
|
|
5
|
+
HistoryBasedMemoriesStore,
|
|
6
|
+
MemoriesStore,
|
|
7
|
+
QueueBasedMemoriesStore,
|
|
8
|
+
StateBackedMemoriesStore,
|
|
9
|
+
)
|
|
10
|
+
from .plugins import (
|
|
11
|
+
LLMCallWrapper,
|
|
12
|
+
PromptGenerator,
|
|
13
|
+
RequestPreprocessor,
|
|
14
|
+
ResponsePostprocessor,
|
|
15
|
+
TemplatedPromptGenerator,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"LLMAgent",
|
|
20
|
+
"LLMAgentConfig",
|
|
21
|
+
"LLMAgentUtils",
|
|
22
|
+
"PromptGenerator",
|
|
23
|
+
"TemplatedPromptGenerator",
|
|
24
|
+
"MemoriesStore",
|
|
25
|
+
"QueueBasedMemoriesStore",
|
|
26
|
+
"HistoryBasedMemoriesStore",
|
|
27
|
+
"StateBackedMemoriesStore",
|
|
28
|
+
"LLMCallWrapper",
|
|
29
|
+
"RequestPreprocessor",
|
|
30
|
+
"ResponsePostprocessor",
|
|
31
|
+
"TemplatedPromptGenerator",
|
|
32
|
+
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from rustic_ai.core.guild.agent import Agent, ProcessContext, processor
|
|
4
|
+
from rustic_ai.core.guild.agent_ext.depends.llm.llm import LLM
|
|
5
|
+
from rustic_ai.core.guild.agent_ext.depends.llm.models import (
|
|
6
|
+
ChatCompletionRequest,
|
|
7
|
+
SystemMessage,
|
|
8
|
+
)
|
|
9
|
+
from rustic_ai.core.messaging.core.message import Message
|
|
10
|
+
from rustic_ai.core.utils.json_utils import JsonDict
|
|
11
|
+
from rustic_ai.llm_agent.llm_agent_conf import LLMAgentConfig
|
|
12
|
+
from rustic_ai.llm_agent.llm_agent_helper import LLMAgentHelper
|
|
13
|
+
from rustic_ai.llm_agent.llm_agent_utils import LLMAgentUtils
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LLMAgent(Agent[LLMAgentConfig]):
|
|
17
|
+
"""
|
|
18
|
+
A simple LLM Agent that simply invokes an LLM.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
_system_prompt: Optional[str] = None
|
|
22
|
+
_updated: bool = False
|
|
23
|
+
|
|
24
|
+
@processor(
|
|
25
|
+
ChatCompletionRequest,
|
|
26
|
+
predicate=lambda self, msg: LLMAgentUtils.has_no_attachments(msg),
|
|
27
|
+
depends_on=["llm"],
|
|
28
|
+
)
|
|
29
|
+
def invoke_llm(self, ctx: ProcessContext[ChatCompletionRequest], llm: LLM):
|
|
30
|
+
"""
|
|
31
|
+
Invoke the LLM with the given context. All the LLM configuration parameters are passed along.
|
|
32
|
+
"""
|
|
33
|
+
prompt = ctx.payload
|
|
34
|
+
|
|
35
|
+
if self._system_prompt:
|
|
36
|
+
# If the system prompt was updated, add it to the messages.
|
|
37
|
+
messages = [SystemMessage(content=self._system_prompt)] + prompt.messages
|
|
38
|
+
prompt = prompt.model_copy(update={"messages": messages})
|
|
39
|
+
elif self.config.default_system_prompt:
|
|
40
|
+
# If there is a default system prompt, use it.
|
|
41
|
+
messages = [SystemMessage(content=self.config.default_system_prompt)] + prompt.messages
|
|
42
|
+
prompt = prompt.model_copy(update={"messages": messages})
|
|
43
|
+
|
|
44
|
+
LLMAgentHelper.invoke_llm_and_handle_response(
|
|
45
|
+
self,
|
|
46
|
+
self.config,
|
|
47
|
+
llm,
|
|
48
|
+
ctx,
|
|
49
|
+
prompt,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def _is_system_prompt_update(self, msg: Message) -> bool:
|
|
53
|
+
config = self.config
|
|
54
|
+
if config.system_prompt_generator and msg.format == config.system_prompt_generator.update_on_message_format:
|
|
55
|
+
return True
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
@processor(JsonDict, predicate=lambda self, msg: self._is_system_prompt_update(msg))
|
|
59
|
+
def update_system_prompt(self, ctx: ProcessContext[JsonDict]):
|
|
60
|
+
"""
|
|
61
|
+
Update the system prompt using the system prompt generator.
|
|
62
|
+
"""
|
|
63
|
+
if not self.config.system_prompt_generator:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
self._system_prompt = (
|
|
67
|
+
self.config.system_prompt_generator.generate_prompt(ctx.payload)
|
|
68
|
+
if self.config.system_prompt_generator
|
|
69
|
+
else self.config.default_system_prompt
|
|
70
|
+
)
|
|
71
|
+
self._updated = True
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Any, List, Optional, Type, TypeVar, Union, cast
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
|
|
5
|
+
from typing_extensions import Annotated
|
|
6
|
+
|
|
7
|
+
from rustic_ai.core.guild.dsl import BaseAgentProps
|
|
8
|
+
from rustic_ai.core.utils.basic_class_utils import get_class_from_name
|
|
9
|
+
from rustic_ai.llm_agent.plugins.llm_call_wrapper import LLMCallWrapper
|
|
10
|
+
from rustic_ai.llm_agent.plugins.prompt_generators import PromptGenerator
|
|
11
|
+
from rustic_ai.llm_agent.plugins.request_preprocessor import RequestPreprocessor
|
|
12
|
+
from rustic_ai.llm_agent.plugins.response_postprocessor import ResponsePostprocessor
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Models(str, Enum):
|
|
16
|
+
gpt_5 = "gpt-5"
|
|
17
|
+
gpt_5_chat = "gpt-5-chat"
|
|
18
|
+
gpt_5_mini = "gpt-5-mini"
|
|
19
|
+
gpt_5_nano = "gpt-5-nano"
|
|
20
|
+
|
|
21
|
+
gpt_4o = "gpt-4o"
|
|
22
|
+
gpt_4o_mini = "gpt-4o-mini"
|
|
23
|
+
|
|
24
|
+
gpt_4_1 = "gpt-4.1"
|
|
25
|
+
gpt_4_1_mini = "gpt-4.1-mini"
|
|
26
|
+
gpt_4_1_nano = "gpt-4.1-nano"
|
|
27
|
+
|
|
28
|
+
o3 = "o3"
|
|
29
|
+
o3_pro = "o3-pro"
|
|
30
|
+
o3_deep_research = "o3-deep-research"
|
|
31
|
+
o3_mini = "o3-mini"
|
|
32
|
+
|
|
33
|
+
o4_mini = "o4-mini"
|
|
34
|
+
|
|
35
|
+
gpt_4 = "gpt-4"
|
|
36
|
+
gpt_4_turbo = "gpt-4-turbo"
|
|
37
|
+
|
|
38
|
+
gemini_pro = "gemini-pro"
|
|
39
|
+
gemini_2_5_pro = "gemini-2.5-pro"
|
|
40
|
+
gemini_flash = "gemini-flash"
|
|
41
|
+
gemini_2_5_flash = "gemini-2.5-flash"
|
|
42
|
+
gemini_2_5_flash_lite = "gemini-2.5-flash-lite"
|
|
43
|
+
|
|
44
|
+
gemini_2_0_flash = "gemini-2.0-flash"
|
|
45
|
+
gemini_2_0_flash_lite = "gemini-2.0-flash-lite"
|
|
46
|
+
|
|
47
|
+
gemini_1_5_pro = "gemini-1.5-pro"
|
|
48
|
+
gemini_1_5_flash = "gemini-1.5-flash"
|
|
49
|
+
|
|
50
|
+
claude_opus_4_1 = "claude-opus-4-1"
|
|
51
|
+
claude_opus_4_0 = "claude-opus-4-0"
|
|
52
|
+
claude_sonnet_4_0 = "claude-sonnet-4-0"
|
|
53
|
+
claude_sonnet_3_5 = "claude-3-5-sonnet-latest"
|
|
54
|
+
claude_haiku_3_5 = "claude-3-5-haiku-latest"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
T = TypeVar("T", bound=BaseModel)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_plugins(value: Any, base_type: type[T]) -> List[T]:
|
|
61
|
+
"""
|
|
62
|
+
Accepts:
|
|
63
|
+
- list of dicts/instances (mixed allowed)
|
|
64
|
+
- single dict
|
|
65
|
+
- single instance
|
|
66
|
+
|
|
67
|
+
Dict shape: {"kind": "pkg.mod.Class", ...kwargs}
|
|
68
|
+
Instances are kept as-is.
|
|
69
|
+
"""
|
|
70
|
+
if value is None:
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
items = value if isinstance(value, list) else [value]
|
|
74
|
+
out: List[T] = []
|
|
75
|
+
|
|
76
|
+
for i, item in enumerate(items):
|
|
77
|
+
# Already constructed instance (programmatic usage)
|
|
78
|
+
if isinstance(item, base_type):
|
|
79
|
+
# mypy: item is a runtime instance of base_type (a subclass of BaseModel)
|
|
80
|
+
out.append(cast(T, item))
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
# Dict spec with FQCN
|
|
84
|
+
if isinstance(item, dict):
|
|
85
|
+
cls_path = item.get("kind")
|
|
86
|
+
if not cls_path or not isinstance(cls_path, str):
|
|
87
|
+
raise TypeError(f"Plugin spec at index {i} must include string 'kind' (FQCN)")
|
|
88
|
+
cls: Type[Any] = get_class_from_name(cls_path)
|
|
89
|
+
if not issubclass(cls, base_type):
|
|
90
|
+
raise TypeError(f"{cls_path!r} is not a subclass of {base_type.__name__}")
|
|
91
|
+
kwargs = {k: v for k, v in item.items() if k != "kind"}
|
|
92
|
+
instance = cls(**kwargs)
|
|
93
|
+
out.append(cast(T, instance))
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
raise TypeError(
|
|
97
|
+
f"Unsupported plugin spec at index {i}: expected dict or {base_type.__name__} instance; "
|
|
98
|
+
f"got {type(item).__name__}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class LLMAgentConfig(BaseAgentProps):
|
|
105
|
+
"""
|
|
106
|
+
Base configuration for LLM agents.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
model_config = ConfigDict(extra="ignore")
|
|
110
|
+
|
|
111
|
+
model: Annotated[Union[str, Models], Field(examples=["gpt-5"])]
|
|
112
|
+
"""
|
|
113
|
+
ID of the model to use. See the [model endpoint compatibility](/docs/models/model-endpoint-compatibility) table
|
|
114
|
+
for details on which models work with the Chat API.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
base_url: Optional[str] = None
|
|
118
|
+
"""Base URL for the LLM API."""
|
|
119
|
+
|
|
120
|
+
api_version: Optional[str] = None
|
|
121
|
+
"""Version for the LLM API."""
|
|
122
|
+
|
|
123
|
+
custom_llm_provider: Optional[str] = None
|
|
124
|
+
"""Custom LLM provider to use, e.g., 'bedrock' for Amazon Bedrock or 'vertex' for Google Vertex AI."""
|
|
125
|
+
|
|
126
|
+
timeout: Optional[float] = None
|
|
127
|
+
"""
|
|
128
|
+
Timeout for the LLM API requests.
|
|
129
|
+
If the completion takes longer than this time, the request will be aborted.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
max_retries: int = 0
|
|
133
|
+
"""
|
|
134
|
+
Maximum number of retries for LLM requests if post processing fails.
|
|
135
|
+
This is useful if the LLM returns a response that can not be processed successfully
|
|
136
|
+
e.g. due to a parsing error in tools response.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
default_system_prompt: Optional[str] = None
|
|
140
|
+
"""
|
|
141
|
+
Default system prompt to use if no other mechanism updates the prompt.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
system_prompt_generator: Optional[PromptGenerator] = Field(discriminator="type", default=None)
|
|
145
|
+
"""
|
|
146
|
+
Mechanism to update the system prompt based on messages from other agents messages.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
request_preprocessors: List[RequestPreprocessor] = Field(default_factory=list)
|
|
150
|
+
"""
|
|
151
|
+
0 or more request preprocessors to apply before sending prompts to the LLM.
|
|
152
|
+
The order of preprocessors matters: they are applied in the order they are listed.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
llm_request_wrappers: List[LLMCallWrapper] = Field(default_factory=list)
|
|
156
|
+
"""
|
|
157
|
+
0 or more request wrap processors to apply before sending prompts to the LLM
|
|
158
|
+
and after receiving the response from the LLM. The plugin may modify the prompts before sending them to the LLM
|
|
159
|
+
and also act on the response after receiving it from the LLM.
|
|
160
|
+
The order of wrap processors matters: they are applied in the order they are listed.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
response_postprocessors: List[ResponsePostprocessor] = Field(default_factory=list)
|
|
164
|
+
"""
|
|
165
|
+
0 or more response postprocessors to apply after receiving the response from the LLM.
|
|
166
|
+
All the post processors get the response as is and can act on it.
|
|
167
|
+
Any modifications to the response don't have any affect on other post processors or the final response.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
send_response: bool = True
|
|
171
|
+
"""
|
|
172
|
+
Whether to send the original LLM response back to the conversation.
|
|
173
|
+
If False, only the post-processed messages (if any) will be sent back.
|
|
174
|
+
This is useful if the LLM response is only used for tool calls and not for direct responses.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
vertex_location: Optional[str] = None
|
|
178
|
+
"""
|
|
179
|
+
Place where vertex model is deployed (us-central1, asia-southeast1, etc.). If None, attempts to use the VERTEXAI_LOCATION environment variable.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
vertex_project: Optional[str] = None
|
|
183
|
+
"""
|
|
184
|
+
The Google Cloud project ID. If None, attempts to use the VERTEXAI_PROJECT environment variable.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
@field_validator("request_preprocessors", mode="before")
|
|
188
|
+
@classmethod
|
|
189
|
+
def _coerce_req(cls, v):
|
|
190
|
+
return _build_plugins(v, RequestPreprocessor)
|
|
191
|
+
|
|
192
|
+
@field_validator("llm_request_wrappers", mode="before")
|
|
193
|
+
@classmethod
|
|
194
|
+
def _coerce_wrap(cls, v):
|
|
195
|
+
return _build_plugins(v, LLMCallWrapper)
|
|
196
|
+
|
|
197
|
+
@field_validator("response_postprocessors", mode="before")
|
|
198
|
+
@classmethod
|
|
199
|
+
def _coerce_resp(cls, v):
|
|
200
|
+
return _build_plugins(v, ResponsePostprocessor)
|
|
201
|
+
|
|
202
|
+
@field_serializer("request_preprocessors", mode="plain")
|
|
203
|
+
def _serialize_preprocessors(self, preprocessors):
|
|
204
|
+
"""Ensure subclass fields are preserved during serialization."""
|
|
205
|
+
return [p.model_dump(exclude_none=True) for p in preprocessors]
|
|
206
|
+
|
|
207
|
+
@field_serializer("llm_request_wrappers", mode="plain")
|
|
208
|
+
def _serialize_wrappers(self, wrappers):
|
|
209
|
+
"""Ensure subclass fields are preserved during serialization."""
|
|
210
|
+
return [w.model_dump(exclude_none=True) for w in wrappers]
|
|
211
|
+
|
|
212
|
+
@field_serializer("response_postprocessors", mode="plain")
|
|
213
|
+
def _serialize_postprocessors(self, postprocessors):
|
|
214
|
+
"""Ensure subclass fields are preserved during serialization."""
|
|
215
|
+
return [p.model_dump(exclude_none=True) for p in postprocessors]
|
|
216
|
+
|
|
217
|
+
_non_llm_fields = {
|
|
218
|
+
"max_retries",
|
|
219
|
+
"request_preprocessors",
|
|
220
|
+
"llm_request_wrappers",
|
|
221
|
+
"response_postprocessors",
|
|
222
|
+
"default_system_prompt",
|
|
223
|
+
"system_prompt_generator",
|
|
224
|
+
"send_response",
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
def get_llm_params(self) -> dict:
|
|
228
|
+
"""
|
|
229
|
+
Get the LLM parameters from the config, excluding non-LLM fields.
|
|
230
|
+
"""
|
|
231
|
+
return self.model_dump(exclude={*self._non_llm_fields})
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import List, Optional, Union
|
|
3
|
+
|
|
4
|
+
import openai
|
|
5
|
+
|
|
6
|
+
from rustic_ai.core.guild.agent import Agent, ProcessContext
|
|
7
|
+
from rustic_ai.core.guild.agent_ext.depends.llm.llm import LLM
|
|
8
|
+
from rustic_ai.core.guild.agent_ext.depends.llm.models import (
|
|
9
|
+
ChatCompletionError,
|
|
10
|
+
ChatCompletionRequest,
|
|
11
|
+
ChatCompletionResponse,
|
|
12
|
+
LLMMessage,
|
|
13
|
+
ResponseCodes,
|
|
14
|
+
)
|
|
15
|
+
from rustic_ai.llm_agent.llm_agent_conf import LLMAgentConfig
|
|
16
|
+
from rustic_ai.llm_agent.plugins.llm_call_wrapper import LLMCallWrapper
|
|
17
|
+
from rustic_ai.llm_agent.plugins.response_postprocessor import ResponsePostprocessor
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LLMAgentHelper:
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def invoke_llm_completion(
|
|
24
|
+
agent: Agent,
|
|
25
|
+
config: LLMAgentConfig,
|
|
26
|
+
llm: LLM,
|
|
27
|
+
ctx: ProcessContext[ChatCompletionRequest],
|
|
28
|
+
prompt: ChatCompletionRequest,
|
|
29
|
+
) -> Union[ChatCompletionResponse, ChatCompletionError]:
|
|
30
|
+
"""
|
|
31
|
+
Invoke the LLM completion with the given context.
|
|
32
|
+
The fields from the chat completion request, Agent Config, and the LLM are combined.
|
|
33
|
+
The LLM Configuration is used as the base, overwritten by Agent config and then
|
|
34
|
+
the Chat Completion Request.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
pre_processors = config.request_preprocessors or []
|
|
38
|
+
wrap_processors = config.llm_request_wrappers or []
|
|
39
|
+
post_processors = config.response_postprocessors or []
|
|
40
|
+
|
|
41
|
+
for pre_processor in pre_processors:
|
|
42
|
+
prompt = pre_processor.preprocess(agent=agent, ctx=ctx, request=prompt, llm=llm)
|
|
43
|
+
|
|
44
|
+
for wrap_processor in wrap_processors:
|
|
45
|
+
prompt = wrap_processor.preprocess(agent=agent, ctx=ctx, request=prompt, llm=llm)
|
|
46
|
+
|
|
47
|
+
config_dict = config.get_llm_params()
|
|
48
|
+
prompt_dict = prompt.model_dump(exclude_none=True)
|
|
49
|
+
|
|
50
|
+
final_prompt = {
|
|
51
|
+
**config_dict,
|
|
52
|
+
**prompt_dict,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
cc_request: ChatCompletionRequest = ChatCompletionRequest.model_validate(final_prompt)
|
|
56
|
+
|
|
57
|
+
max_retries = config.max_retries
|
|
58
|
+
|
|
59
|
+
return LLMAgentHelper._invoke_llm_completion(
|
|
60
|
+
agent=agent,
|
|
61
|
+
ctx=ctx,
|
|
62
|
+
llm=llm,
|
|
63
|
+
cc_request=cc_request,
|
|
64
|
+
wrap_processors=wrap_processors,
|
|
65
|
+
post_processors=post_processors,
|
|
66
|
+
max_retries=max_retries,
|
|
67
|
+
model=config.model,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def _invoke_llm_completion(
|
|
72
|
+
agent: Agent,
|
|
73
|
+
ctx: ProcessContext[ChatCompletionRequest],
|
|
74
|
+
llm: LLM,
|
|
75
|
+
cc_request: ChatCompletionRequest,
|
|
76
|
+
wrap_processors: List[LLMCallWrapper],
|
|
77
|
+
post_processors: List[ResponsePostprocessor],
|
|
78
|
+
max_retries: int,
|
|
79
|
+
model: Optional[str] = None,
|
|
80
|
+
) -> Union[ChatCompletionResponse, ChatCompletionError]:
|
|
81
|
+
|
|
82
|
+
response = llm.completion(cc_request, model)
|
|
83
|
+
|
|
84
|
+
new_messages = []
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
for wrap_processor in reversed(wrap_processors):
|
|
88
|
+
post_msg = wrap_processor.postprocess(
|
|
89
|
+
agent=agent,
|
|
90
|
+
ctx=ctx,
|
|
91
|
+
final_prompt=cc_request,
|
|
92
|
+
llm_response=response,
|
|
93
|
+
llm=llm,
|
|
94
|
+
)
|
|
95
|
+
if post_msg:
|
|
96
|
+
new_messages.extend(post_msg)
|
|
97
|
+
|
|
98
|
+
for post_processor in post_processors:
|
|
99
|
+
post_msg = post_processor.postprocess(
|
|
100
|
+
agent=agent,
|
|
101
|
+
ctx=ctx,
|
|
102
|
+
final_prompt=cc_request,
|
|
103
|
+
llm_response=response,
|
|
104
|
+
llm=llm,
|
|
105
|
+
)
|
|
106
|
+
if post_msg:
|
|
107
|
+
new_messages.extend(post_msg)
|
|
108
|
+
|
|
109
|
+
reasoning = response.choices[0].message.reasoning_content if response.choices else ""
|
|
110
|
+
|
|
111
|
+
for msg in new_messages:
|
|
112
|
+
if reasoning:
|
|
113
|
+
ctx.send(msg, reason=reasoning)
|
|
114
|
+
else:
|
|
115
|
+
ctx.send(msg)
|
|
116
|
+
|
|
117
|
+
except Exception as e: # pragma: no cover
|
|
118
|
+
logging.error(f"Error in post processing: {e}")
|
|
119
|
+
if max_retries > 0:
|
|
120
|
+
logging.info(f"Retrying LLM call, remaining retries: {max_retries}")
|
|
121
|
+
return LLMAgentHelper._invoke_llm_completion(
|
|
122
|
+
agent=agent,
|
|
123
|
+
ctx=ctx,
|
|
124
|
+
llm=llm,
|
|
125
|
+
cc_request=cc_request,
|
|
126
|
+
wrap_processors=wrap_processors,
|
|
127
|
+
post_processors=post_processors,
|
|
128
|
+
max_retries=max_retries - 1,
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
return ChatCompletionError(
|
|
132
|
+
status_code=ResponseCodes.RESPONSE_PROCESSING_ERROR,
|
|
133
|
+
message=str(e),
|
|
134
|
+
model=agent.name,
|
|
135
|
+
request_messages=ctx.payload.messages,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return response
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def process_api_status_error(
|
|
142
|
+
model_name: str,
|
|
143
|
+
ctx: ProcessContext[ChatCompletionRequest],
|
|
144
|
+
status_code: ResponseCodes,
|
|
145
|
+
error: openai.APIStatusError,
|
|
146
|
+
messages: List[Union[LLMMessage]],
|
|
147
|
+
) -> ChatCompletionError: # pragma: no cover
|
|
148
|
+
"""
|
|
149
|
+
Process API status error and return a ChatCompletionError object.
|
|
150
|
+
"""
|
|
151
|
+
cc_error = ChatCompletionError(
|
|
152
|
+
status_code=status_code,
|
|
153
|
+
message=error.message,
|
|
154
|
+
response=error.response.text if error.response else None,
|
|
155
|
+
model=model_name,
|
|
156
|
+
request_messages=messages,
|
|
157
|
+
body=error.body if hasattr(error, "body") else None,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return cc_error
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def invoke_llm_and_handle_response(
|
|
164
|
+
agent: Agent,
|
|
165
|
+
config: LLMAgentConfig,
|
|
166
|
+
llm: LLM,
|
|
167
|
+
ctx: ProcessContext[ChatCompletionRequest],
|
|
168
|
+
prompt: ChatCompletionRequest,
|
|
169
|
+
) -> ChatCompletionResponse | ChatCompletionError:
|
|
170
|
+
"""
|
|
171
|
+
Invoke the LLM and handle the response.
|
|
172
|
+
The fields from the chat completion request, Agent Config, and the LLM are combined.
|
|
173
|
+
The LLM Configuration is used as the base, overwritten by Agent config and then
|
|
174
|
+
the Chat Completion Request.
|
|
175
|
+
"""
|
|
176
|
+
try:
|
|
177
|
+
chat_response = LLMAgentHelper.invoke_llm_completion(agent, config, llm, ctx, prompt)
|
|
178
|
+
|
|
179
|
+
if isinstance(chat_response, ChatCompletionError):
|
|
180
|
+
ctx.send_error(chat_response)
|
|
181
|
+
return chat_response
|
|
182
|
+
|
|
183
|
+
reasoning = chat_response.choices[0].message.reasoning_content if chat_response.choices else ""
|
|
184
|
+
|
|
185
|
+
if reasoning:
|
|
186
|
+
ctx.send(chat_response, reason=reasoning)
|
|
187
|
+
else:
|
|
188
|
+
ctx.send(chat_response)
|
|
189
|
+
return chat_response
|
|
190
|
+
except openai.APIStatusError as e: # pragma: no cover
|
|
191
|
+
logging.error(f"Error in LLM completion: {e}")
|
|
192
|
+
# Publish the error message
|
|
193
|
+
error = LLMAgentHelper.process_api_status_error(
|
|
194
|
+
model_name=config.model,
|
|
195
|
+
ctx=ctx,
|
|
196
|
+
status_code=ResponseCodes(e.status_code),
|
|
197
|
+
error=e,
|
|
198
|
+
messages=ctx.payload.messages,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
ctx.send_error(error)
|
|
202
|
+
return error
|
|
203
|
+
except Exception as e: # pragma: no cover
|
|
204
|
+
logging.error(f"Unexpected error in LLM completion: {e}")
|
|
205
|
+
error = ChatCompletionError(
|
|
206
|
+
status_code=ResponseCodes.INTERNAL_SERVER_ERROR,
|
|
207
|
+
message=str(e),
|
|
208
|
+
model=agent.name,
|
|
209
|
+
request_messages=ctx.payload.messages,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
ctx.send_error(error)
|
|
213
|
+
return error
|