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.
Files changed (24) hide show
  1. rusticai_llm_agent-1.0.0/PKG-INFO +36 -0
  2. rusticai_llm_agent-1.0.0/README.md +18 -0
  3. rusticai_llm_agent-1.0.0/pyproject.toml +77 -0
  4. rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/__init__.py +32 -0
  5. rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/llm_agent.py +71 -0
  6. rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/llm_agent_conf.py +231 -0
  7. rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/llm_agent_helper.py +213 -0
  8. rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/llm_agent_utils.py +167 -0
  9. rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/memories/__init__.py +11 -0
  10. rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/memories/guild_state_memories_store.py +26 -0
  11. rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/memories/history_memories_store.py +94 -0
  12. rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/memories/memories_store.py +81 -0
  13. rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/memories/queue_memories_store.py +28 -0
  14. rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/memories/state_memories_store.py +48 -0
  15. rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/model.py +16 -0
  16. rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/plugins/__init__.py +12 -0
  17. rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/plugins/llm_call_wrapper.py +62 -0
  18. rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/plugins/prompt_generators.py +30 -0
  19. rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/plugins/request_preprocessor.py +42 -0
  20. rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/plugins/response_postprocessor.py +47 -0
  21. rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/tool_enabled_agent.py +0 -0
  22. rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/tools/__init__.py +13 -0
  23. rusticai_llm_agent-1.0.0/src/rustic_ai/llm_agent/tools/tools_manager_plugin.py +173 -0
  24. 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