Skip to content

Commit 9f30c2b

Browse files
Brian KrafftCopilot
andcommitted
feat(5.8): extract ExecutionLoop + PromptBuilder into grounding/ package
- Extract process() (~287 lines) into grounding/execution.py - Extract construct_messages(), _default_system_prompt() into grounding/prompts.py - Helper _build_retrieved_tools_list() extracted from process() body - grounding_agent.py methods become thin delegates - 26 new tests (14 prompts + 12 execution) - 1,636 passed, 127 skipped Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cf28723 commit 9f30c2b

5 files changed

Lines changed: 775 additions & 326 deletions

File tree

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
"""Core execution loop for GroundingAgent.
2+
3+
Implements the multi-round LLM iteration with tool calling,
4+
skill-context stripping, message truncation, and result building.
5+
Extracted from grounding_agent.py (Epic 5.8).
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import copy
11+
from typing import Any, Dict, List, Optional
12+
13+
from openspace.prompts import GroundingAgentPrompts
14+
from openspace.utils.logging import Logger
15+
16+
logger = Logger.get_logger("openspace.agents.grounding_agent")
17+
18+
# Exit after this many consecutive empty LLM responses.
19+
_MAX_CONSECUTIVE_EMPTY = 5
20+
21+
22+
async def process(agent, context: Dict[str, Any]) -> Dict[str, Any]:
23+
"""Execute a task with multi-round LLM iteration control.
24+
25+
This is the main entry-point for task processing. It:
26+
1. Validates the instruction.
27+
2. Checks for existing workspace artifacts.
28+
3. Retrieves available tools.
29+
4. Constructs initial messages (via ``agent.construct_messages``).
30+
5. Enters the LLM loop (up to *max_iterations* rounds).
31+
6. Builds and returns the final result dict.
32+
33+
Args:
34+
agent: GroundingAgent instance.
35+
context: Execution context (must contain ``instruction``).
36+
37+
Returns:
38+
Result dict with status, output, tool results, etc.
39+
"""
40+
instruction = context.get("instruction", "")
41+
if not instruction:
42+
logger.error("Grounding Agent: No instruction provided")
43+
return {"error": "No instruction provided", "status": "error"}
44+
45+
# Store current instruction for visual analysis context
46+
agent._current_instruction = instruction
47+
48+
logger.info(f"Grounding Agent: Processing instruction at step {agent.step}")
49+
50+
# Existing workspace files check
51+
workspace_info = await agent._check_workspace_artifacts(context)
52+
if workspace_info["has_files"]:
53+
context["workspace_artifacts"] = workspace_info
54+
logger.info(
55+
f"Workspace has {len(workspace_info['files'])} existing files: "
56+
f"{workspace_info['files']}"
57+
)
58+
59+
# Get available tools (auto-search with cap)
60+
tools = await agent._get_available_tools(instruction)
61+
agent._last_tools = tools # expose for post-execution analysis
62+
63+
# Get search debug info (similarity scores, LLM selections)
64+
search_debug_info = None
65+
if agent.grounding_client:
66+
search_debug_info = agent.grounding_client.get_last_search_debug_info()
67+
68+
# Build retrieved tools list for return value
69+
retrieved_tools_list = _build_retrieved_tools_list(tools, search_debug_info)
70+
71+
# Record retrieved tools
72+
if agent._recording_manager:
73+
from openspace.recording import RecordingManager
74+
75+
await RecordingManager.record_retrieved_tools(
76+
task_instruction=instruction,
77+
tools=tools,
78+
search_debug_info=search_debug_info,
79+
)
80+
81+
# Initialize iteration state
82+
max_iterations = context.get("max_iterations", agent._max_iterations)
83+
current_iteration = 0
84+
all_tool_results: List[Dict] = []
85+
iteration_contexts: List[Dict] = []
86+
consecutive_empty_responses = 0
87+
88+
# Build initial messages
89+
messages = agent.construct_messages(context)
90+
91+
# Record initial conversation setup once
92+
from openspace.recording import RecordingManager
93+
94+
await RecordingManager.record_conversation_setup(
95+
setup_messages=copy.deepcopy(messages),
96+
tools=tools,
97+
)
98+
99+
try:
100+
while current_iteration < max_iterations:
101+
current_iteration += 1
102+
logger.info(
103+
f"Grounding Agent: Iteration {current_iteration}/{max_iterations}"
104+
)
105+
106+
# Strip skill context after the first iteration to save prompt tokens.
107+
if current_iteration == 2 and agent._skill_context:
108+
skill_ctx = agent._skill_context
109+
messages = [
110+
m
111+
for m in messages
112+
if not (
113+
m.get("role") == "system"
114+
and m.get("content") == skill_ctx
115+
)
116+
]
117+
logger.info(
118+
"Skill context removed from messages after first iteration"
119+
)
120+
121+
# Cap oversized individual messages every iteration
122+
if current_iteration >= 2:
123+
messages = agent._cap_message_content(messages)
124+
125+
# Truncate message history after 5 iterations
126+
if current_iteration >= 5:
127+
messages = agent._truncate_messages(
128+
messages, keep_recent=8, max_tokens_estimate=120_000
129+
)
130+
131+
messages_input_snapshot = copy.deepcopy(messages)
132+
133+
# Call LLMClient for single round
134+
llm_response = await agent._llm_client.complete(
135+
messages=messages,
136+
tools=tools if context.get("auto_execute", True) else None,
137+
execute_tools=context.get("auto_execute", True),
138+
summary_prompt=None,
139+
tool_result_callback=agent._visual_analysis_callback,
140+
)
141+
142+
# Update messages with LLM response
143+
messages = llm_response["messages"]
144+
145+
# Collect tool results
146+
tool_results_this_iteration = llm_response.get("tool_results", [])
147+
if tool_results_this_iteration:
148+
all_tool_results.extend(tool_results_this_iteration)
149+
150+
assistant_message = llm_response.get("message", {})
151+
assistant_content = assistant_message.get("content", "")
152+
153+
has_tool_calls = llm_response.get("has_tool_calls", False)
154+
logger.info(
155+
f"Iteration {current_iteration} - Has tool calls: {has_tool_calls}, "
156+
f"Tool results: {len(tool_results_this_iteration)}, "
157+
f"Content length: {len(assistant_content)} chars"
158+
)
159+
160+
if len(assistant_content) > 0:
161+
logger.info(
162+
f"Iteration {current_iteration} - Assistant content preview: "
163+
f"{repr(assistant_content[:300])}"
164+
)
165+
consecutive_empty_responses = 0
166+
else:
167+
if not has_tool_calls:
168+
consecutive_empty_responses += 1
169+
logger.warning(
170+
f"Iteration {current_iteration} - NO tool calls and NO content "
171+
f"(empty response {consecutive_empty_responses}/"
172+
f"{_MAX_CONSECUTIVE_EMPTY})"
173+
)
174+
175+
if consecutive_empty_responses >= _MAX_CONSECUTIVE_EMPTY:
176+
logger.error(
177+
f"Exiting due to {_MAX_CONSECUTIVE_EMPTY} consecutive "
178+
"empty LLM responses. This may indicate API issues, "
179+
"rate limiting, or context too long."
180+
)
181+
break
182+
else:
183+
consecutive_empty_responses = 0
184+
185+
# Snapshot messages after LLM call
186+
messages_output_snapshot = copy.deepcopy(messages)
187+
188+
# Delta messages: only produced in this iteration
189+
delta_messages = messages[len(messages_input_snapshot):]
190+
191+
# Response metadata
192+
response_metadata = {
193+
"has_tool_calls": has_tool_calls,
194+
"tool_calls_count": len(tool_results_this_iteration),
195+
}
196+
iteration_context = {
197+
"iteration": current_iteration,
198+
"messages_input": messages_input_snapshot,
199+
"messages_output": messages_output_snapshot,
200+
"response_metadata": response_metadata,
201+
}
202+
iteration_contexts.append(iteration_context)
203+
204+
# Real-time save to conversations.jsonl (delta only)
205+
await RecordingManager.record_iteration_context(
206+
iteration=current_iteration,
207+
delta_messages=copy.deepcopy(delta_messages),
208+
response_metadata=response_metadata,
209+
)
210+
211+
# Check for completion token
212+
is_complete = GroundingAgentPrompts.TASK_COMPLETE in assistant_content
213+
214+
if is_complete:
215+
logger.info(
216+
f"Task completed at iteration {current_iteration} "
217+
f"(found {GroundingAgentPrompts.TASK_COMPLETE})"
218+
)
219+
break
220+
else:
221+
if tool_results_this_iteration:
222+
logger.debug(
223+
f"Task in progress, LLM called "
224+
f"{len(tool_results_this_iteration)} tools"
225+
)
226+
else:
227+
logger.debug(
228+
"Task in progress, LLM did not generate <COMPLETE>"
229+
)
230+
231+
# Remove previous iteration guidance to avoid accumulation
232+
messages = [
233+
msg
234+
for msg in messages
235+
if not (
236+
msg.get("role") == "system"
237+
and "Iteration" in msg.get("content", "")
238+
and "complete" in msg.get("content", "")
239+
)
240+
]
241+
242+
guidance_msg = {
243+
"role": "system",
244+
"content": (
245+
f"Iteration {current_iteration} complete. "
246+
f"Check if task is finished - if yes, output "
247+
f"{GroundingAgentPrompts.TASK_COMPLETE}. "
248+
f"If not, continue with next action."
249+
),
250+
}
251+
messages.append(guidance_msg)
252+
continue
253+
254+
# Build final result
255+
result = await agent._build_final_result(
256+
instruction=instruction,
257+
messages=messages,
258+
all_tool_results=all_tool_results,
259+
iterations=current_iteration,
260+
max_iterations=max_iterations,
261+
iteration_contexts=iteration_contexts,
262+
retrieved_tools_list=retrieved_tools_list,
263+
search_debug_info=search_debug_info,
264+
)
265+
266+
# Record agent action
267+
if agent._recording_manager:
268+
await agent._record_agent_execution(result, instruction)
269+
270+
# Increment step
271+
agent.increment_step()
272+
273+
logger.info(
274+
f"Grounding Agent: Execution completed with status: "
275+
f"{result.get('status')}"
276+
)
277+
return result
278+
279+
except Exception as e:
280+
logger.error(f"Grounding Agent: Execution failed: {e}")
281+
result = {
282+
"error": str(e),
283+
"status": "error",
284+
"instruction": instruction,
285+
"iteration": current_iteration,
286+
}
287+
agent.increment_step()
288+
return result
289+
290+
291+
def _build_retrieved_tools_list(
292+
tools: List, search_debug_info: Optional[Dict[str, Any]]
293+
) -> List[Dict[str, Any]]:
294+
"""Build the retrieved-tools metadata list for the result dict."""
295+
retrieved_tools_list: List[Dict[str, Any]] = []
296+
for tool in tools:
297+
tool_info: Dict[str, Any] = {
298+
"name": getattr(tool, "name", str(tool)),
299+
"description": getattr(tool, "description", ""),
300+
}
301+
# Prefer runtime_info.backend over backend_type
302+
runtime_info = getattr(tool, "_runtime_info", None)
303+
if runtime_info and hasattr(runtime_info, "backend"):
304+
tool_info["backend"] = (
305+
runtime_info.backend.value
306+
if hasattr(runtime_info.backend, "value")
307+
else str(runtime_info.backend)
308+
)
309+
tool_info["server_name"] = runtime_info.server_name
310+
elif hasattr(tool, "backend_type"):
311+
tool_info["backend"] = (
312+
tool.backend_type.value
313+
if hasattr(tool.backend_type, "value")
314+
else str(tool.backend_type)
315+
)
316+
317+
# Add similarity score if available
318+
if search_debug_info and search_debug_info.get("tool_scores"):
319+
for score_info in search_debug_info["tool_scores"]:
320+
if score_info["name"] == tool_info["name"]:
321+
tool_info["similarity_score"] = score_info["score"]
322+
break
323+
324+
retrieved_tools_list.append(tool_info)
325+
return retrieved_tools_list

0 commit comments

Comments
 (0)