Skip to content

Commit 40394cc

Browse files
Merge pull request #170 from vamplabAI/iron-agent
Iron agent
2 parents 774efbb + 5b5c74b commit 40394cc

File tree

10 files changed

+1867
-4
lines changed

10 files changed

+1867
-4
lines changed

sgr_agent_core/agents/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""Agents module for SGR Agent Core."""
22

3+
from sgr_agent_core.agents.iron_agent import IronAgent
34
from sgr_agent_core.agents.sgr_agent import SGRAgent
45
from sgr_agent_core.agents.sgr_tool_calling_agent import SGRToolCallingAgent
56
from sgr_agent_core.agents.tool_calling_agent import ToolCallingAgent
67

78
__all__ = [
9+
"IronAgent",
810
"SGRAgent",
911
"SGRToolCallingAgent",
1012
"ToolCallingAgent",
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
"""Fuzzy Agent that doesn't rely on tool calling or structured output."""
2+
3+
from datetime import datetime
4+
from typing import Type
5+
6+
from openai import AsyncOpenAI
7+
from pydantic import BaseModel
8+
9+
from sgr_agent_core.agent_config import AgentConfig
10+
from sgr_agent_core.base_agent import BaseAgent
11+
from sgr_agent_core.next_step_tool import NextStepToolsBuilder
12+
from sgr_agent_core.services.registry import ToolRegistry
13+
from sgr_agent_core.services.tool_instantiator import ToolInstantiator
14+
from sgr_agent_core.tools import BaseTool, ReasoningTool, ToolNameSelectorStub
15+
16+
17+
class IronAgent(BaseAgent):
18+
"""Agent that uses flexible parsing of LLM text responses instead of tool
19+
calling.
20+
21+
This agent doesn't rely on:
22+
- Tool calling (function calling)
23+
- Structured output (response_format)
24+
25+
Instead, it parses natural language responses from LLM to determine
26+
which tool to use and with what parameters using ToolInstantiator.
27+
"""
28+
29+
name: str = "iron_agent"
30+
31+
def __init__(
32+
self,
33+
task_messages: list,
34+
openai_client: AsyncOpenAI,
35+
agent_config: AgentConfig,
36+
toolkit: list[Type[BaseTool]],
37+
def_name: str | None = None,
38+
**kwargs: dict,
39+
):
40+
super().__init__(
41+
task_messages=task_messages,
42+
openai_client=openai_client,
43+
agent_config=agent_config,
44+
toolkit=toolkit,
45+
def_name=def_name,
46+
**kwargs,
47+
)
48+
49+
def _log_tool_instantiator(
50+
self,
51+
instantiator: ToolInstantiator,
52+
attempt: int,
53+
max_retries: int,
54+
):
55+
"""Log tool generation attempt by LLM using data from ToolInstantiator.
56+
57+
Args:
58+
instantiator: ToolInstantiator instance with attempt data
59+
attempt: Current attempt number (1-based)
60+
max_retries: Maximum number of retry attempts
61+
"""
62+
success = instantiator.instance is not None
63+
errors_formatted = instantiator.input_content + "\n" + "\n".join(instantiator.errors)
64+
self.logger.info(
65+
f"""
66+
###############################################
67+
TOOL GENERATION DEBUG
68+
{"✅" if success else "❌"} ATTEMPT {attempt}/{max_retries} {"SUCCESS" if success else "FAILED"}:
69+
70+
Tool: {instantiator.tool_class.tool_name} - Class: {instantiator.tool_class.__name__}
71+
72+
{errors_formatted if not success else ""}
73+
###############################################"""
74+
)
75+
76+
self.log.append(
77+
{
78+
"step_number": self._context.iteration,
79+
"timestamp": datetime.now().isoformat(),
80+
"step_type": "tool_generation_attempt",
81+
"tool_class": instantiator.tool_class.__name__,
82+
"attempt": attempt,
83+
"max_retries": max_retries,
84+
"success": success,
85+
"llm_content": instantiator.input_content,
86+
"errors": instantiator.errors.copy(),
87+
}
88+
)
89+
90+
async def _generate_tool(
91+
self,
92+
tool_class: Type[BaseTool],
93+
messages: list[dict],
94+
max_retries: int = 5,
95+
) -> BaseTool | BaseModel:
96+
"""Generate tool instance from LLM response using ToolInstantiator.
97+
98+
Universal method for calling LLM with parsing through ToolInstantiator.
99+
Handles retries with error accumulation.
100+
101+
Args:
102+
tool_class: Tool class or model class to instantiate
103+
messages: Context messages for LLM
104+
max_retries: Maximum number of retry attempts
105+
106+
Returns:
107+
Instance of tool_class
108+
109+
Raises:
110+
ValueError: If parsing fails after max_retries attempts
111+
"""
112+
instantiator = ToolInstantiator(tool_class)
113+
114+
for attempt in range(max_retries):
115+
async with self.openai_client.chat.completions.stream(
116+
messages=messages + [{"role": "user", "content": instantiator.generate_format_prompt()}],
117+
**self.config.llm.to_openai_client_kwargs(),
118+
) as stream:
119+
async for event in stream:
120+
if event.type == "chunk":
121+
self.streaming_generator.add_chunk(event.chunk)
122+
123+
completion = await stream.get_final_completion()
124+
content = completion.choices[0].message.content
125+
try:
126+
tool_instance = instantiator.build_model(content)
127+
return tool_instance
128+
except ValueError:
129+
continue
130+
finally:
131+
self._log_tool_instantiator(
132+
instantiator=instantiator,
133+
attempt=attempt + 1,
134+
max_retries=max_retries,
135+
)
136+
137+
raise ValueError(
138+
f"Failed to parse {tool_class.__name__} after {max_retries} attempts. "
139+
f"Try to simplify tool schema or provide more detailed instructions."
140+
)
141+
142+
async def _prepare_tools(self) -> Type[ToolNameSelectorStub]:
143+
"""Prepare available tools for the current agent state and progress."""
144+
if self._context.iteration >= self.config.execution.max_iterations:
145+
raise RuntimeError("Max iterations reached")
146+
return NextStepToolsBuilder.build_NextStepToolSelector(self.toolkit)
147+
148+
async def _reasoning_phase(self) -> ReasoningTool:
149+
"""Call LLM to get ReasoningTool with selected tool name."""
150+
messages = await self._prepare_context()
151+
152+
tool_selector_model = await self._prepare_tools()
153+
reasoning = await self._generate_tool(tool_selector_model, messages)
154+
155+
if not isinstance(reasoning, ReasoningTool):
156+
raise ValueError("Expected ReasoningTool instance")
157+
158+
# Log reasoning
159+
self._log_reasoning(reasoning)
160+
161+
# Add to streaming
162+
self.streaming_generator.add_tool_call(
163+
f"{self._context.iteration}-reasoning",
164+
reasoning.tool_name,
165+
reasoning.model_dump_json(exclude={"function_name_choice"}),
166+
)
167+
168+
return reasoning
169+
170+
async def _select_action_phase(self, reasoning: ReasoningTool) -> BaseTool:
171+
"""Select tool based on reasoning phase result."""
172+
messages = await self._prepare_context()
173+
174+
tool_name = reasoning.function_name_choice # type: ignore
175+
176+
# Find tool class by name
177+
tool_class: Type[BaseTool] | None = None
178+
179+
# Try ToolRegistry first
180+
tool_class = ToolRegistry.get(tool_name)
181+
182+
# If not found, search in toolkit
183+
if tool_class is None:
184+
for tool in self.toolkit:
185+
if tool.tool_name == tool_name:
186+
tool_class = tool
187+
break
188+
189+
if tool_class is None:
190+
raise ValueError(f"Tool '{tool_name}' not found in toolkit")
191+
192+
# Generate tool parameters
193+
tool = await self._generate_tool(tool_class, messages)
194+
195+
if not isinstance(tool, BaseTool):
196+
raise ValueError("Selected tool is not a valid BaseTool instance")
197+
198+
# Add to conversation
199+
self.conversation.append(
200+
{
201+
"role": "assistant",
202+
"content": reasoning.remaining_steps[0] if reasoning.remaining_steps else "Completing",
203+
"tool_calls": [
204+
{
205+
"type": "function",
206+
"id": f"{self._context.iteration}-action",
207+
"function": {
208+
"name": tool.tool_name,
209+
"arguments": tool.model_dump_json(),
210+
},
211+
}
212+
],
213+
}
214+
)
215+
self.streaming_generator.add_tool_call(
216+
f"{self._context.iteration}-action", tool.tool_name, tool.model_dump_json()
217+
)
218+
219+
return tool
220+
221+
async def _action_phase(self, tool: BaseTool) -> str:
222+
"""Execute selected tool."""
223+
result = await tool(self._context, self.config)
224+
self.conversation.append(
225+
{"role": "tool", "content": result, "tool_call_id": f"{self._context.iteration}-action"}
226+
)
227+
self.streaming_generator.add_chunk_from_str(f"{result}\n")
228+
self._log_tool_execution(tool, result)
229+
return result

sgr_agent_core/next_step_tool.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ class NextStepToolStub(ReasoningTool, ABC):
2323
function: T = Field(description="Select the appropriate tool for the next step")
2424

2525

26+
class ToolNameSelectorStub(ReasoningTool, ABC):
27+
"""Stub class for tool name selection that inherits from ReasoningTool.
28+
29+
Used by IronAgent to select tool name as part of reasoning phase.
30+
(!) Stub class for correct autocomplete. Use
31+
NextStepToolsBuilder.build_NextStepToolSelector
32+
"""
33+
34+
function_name_choice: str = Field(description="Select the name of the tool to use")
35+
36+
2637
class DiscriminantToolMixin(BaseModel):
2738
tool_name_discriminator: str = Field(..., description="Tool name discriminator")
2839

@@ -60,8 +71,35 @@ def _create_tool_types_union(cls, tools_list: list[Type[T]]) -> Type:
6071

6172
@classmethod
6273
def build_NextStepTools(cls, tools_list: list[Type[T]]) -> Type[NextStepToolStub]: # noqa
74+
"""Build a model with all NextStepTool args."""
6375
return create_model(
6476
"NextStepTools",
6577
__base__=NextStepToolStub,
66-
function=(cls._create_tool_types_union(tools_list), Field()),
78+
function=(
79+
cls._create_tool_types_union(tools_list),
80+
Field(description="Select and fill parameters of the appropriate tool for the next step"),
81+
),
82+
)
83+
84+
@classmethod
85+
def build_NextStepToolSelector(cls, tools_list: list[Type[T]]) -> Type[ToolNameSelectorStub]:
86+
"""Build a model for selecting tool name."""
87+
# Extract tool names and descriptions
88+
tool_names = [tool.tool_name for tool in tools_list]
89+
90+
if len(tool_names) == 1:
91+
literal_type = Literal[tool_names[0]]
92+
else:
93+
# Create union of individual Literal types using operator.or_
94+
# Literal["a"] | Literal["b"] is equivalent to Literal["a", "b"]
95+
literal_types = [Literal[name] for name in tool_names]
96+
literal_type = reduce(operator.or_, literal_types)
97+
98+
# Create model dynamically, inheriting from ToolNameSelectorStub (which inherits from ReasoningTool)
99+
model_class = create_model(
100+
"NextStepToolSelector",
101+
__base__=ToolNameSelectorStub,
102+
function_name_choice=(literal_type, Field(description="Choose the name for the best tool to use")),
67103
)
104+
model_class.tool_name = "nextsteptoolselector" # type: ignore
105+
return model_class

sgr_agent_core/services/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from sgr_agent_core.services.prompt_loader import PromptLoader
55
from sgr_agent_core.services.registry import AgentRegistry, StreamingGeneratorRegistry, ToolRegistry
66
from sgr_agent_core.services.tavily_search import TavilySearchService
7+
from sgr_agent_core.services.tool_instantiator import ToolInstantiator
78

89
__all__ = [
910
"TavilySearchService",
@@ -12,4 +13,5 @@
1213
"StreamingGeneratorRegistry",
1314
"AgentRegistry",
1415
"PromptLoader",
16+
"ToolInstantiator",
1517
]

0 commit comments

Comments
 (0)