Skip to content

Commit d14f18d

Browse files
Brian KrafftCopilot
andcommitted
feat(5.7): extract SkillContext + MessageSafety into grounding/ package
- Create openspace/agents/grounding/ package - Extract set_skill_context, clear_skill_context, has_skill_context, set_skill_registry into grounding/context.py (68 lines) - Extract cap_message_content, truncate_messages into grounding/messages.py (120 lines) - grounding_agent.py methods become thin delegates - 33 new tests (14 context + 19 messages) - 1,609 passed, 127 skipped Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 39724e6 commit d14f18d

6 files changed

Lines changed: 512 additions & 92 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# openspace.agents.grounding — GroundingAgent subsystem package
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Skill-context and skill-registry helpers for GroundingAgent.
2+
3+
Pure side-effect functions that operate on agent instance state.
4+
Extracted from grounding_agent.py (Epic 5.7).
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from typing import TYPE_CHECKING, List, Optional
10+
11+
from openspace.utils.logging import Logger
12+
13+
if TYPE_CHECKING:
14+
from openspace.skill_engine import SkillRegistry
15+
16+
logger = Logger.get_logger("openspace.agents.grounding_agent")
17+
18+
19+
def set_skill_context(
20+
agent,
21+
context: str,
22+
skill_ids: Optional[List[str]] = None,
23+
) -> None:
24+
"""Inject skill guidance into the agent's system prompt.
25+
26+
Called by ``OpenSpace.execute()`` before ``process()`` when skills
27+
are matched. The context is a formatted string built by
28+
``SkillRegistry.build_context_injection()``.
29+
30+
Args:
31+
agent: GroundingAgent instance.
32+
context: Formatted skill content for system prompt injection.
33+
skill_ids: skill_id values of injected skills.
34+
"""
35+
agent._skill_context = context if context else None
36+
agent._active_skill_ids = skill_ids or []
37+
if agent._skill_context:
38+
logger.info(
39+
f"Skill context set: {', '.join(agent._active_skill_ids) or '(unnamed)'}"
40+
)
41+
42+
43+
def clear_skill_context(agent) -> None:
44+
"""Remove skill guidance (used before fallback execution)."""
45+
if agent._skill_context:
46+
logger.info(
47+
f"Skill context cleared (was: {', '.join(agent._active_skill_ids)})"
48+
)
49+
agent._skill_context = None
50+
agent._active_skill_ids = []
51+
52+
53+
def has_skill_context(agent) -> bool:
54+
"""Return True if skill context is currently set."""
55+
return agent._skill_context is not None
56+
57+
58+
def set_skill_registry(agent, registry: Optional["SkillRegistry"]) -> None:
59+
"""Attach a SkillRegistry so the agent can offer ``retrieve_skill`` as a tool."""
60+
agent._skill_registry = registry
61+
if registry:
62+
count = len(registry.list_skills())
63+
logger.info(
64+
f"Skill registry attached ({count} skill(s) available for mid-iteration retrieval)"
65+
)
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Message safety helpers for GroundingAgent.
2+
3+
Functions to cap oversized message content and truncate long
4+
conversation histories before LLM calls.
5+
Extracted from grounding_agent.py (Epic 5.7).
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
from typing import Any, Dict, List
12+
13+
from openspace.utils.logging import Logger
14+
15+
logger = Logger.get_logger("openspace.agents.grounding_agent")
16+
17+
# Maximum characters allowed in a single message content field.
18+
_MAX_SINGLE_CONTENT_CHARS = 30_000
19+
20+
21+
def cap_message_content(
22+
messages: List[Dict[str, Any]],
23+
cap: int = _MAX_SINGLE_CONTENT_CHARS,
24+
) -> List[Dict[str, Any]]:
25+
"""Truncate oversized individual message contents in-place.
26+
27+
Targets tool-result messages and assistant messages that can
28+
carry enormous file contents (read_file on large CSVs/scripts).
29+
System messages and the first user instruction are never touched.
30+
31+
Args:
32+
messages: The message list (mutated in-place).
33+
cap: Maximum character count per message.
34+
35+
Returns:
36+
The same *messages* list (for chaining).
37+
"""
38+
trimmed = 0
39+
for msg in messages:
40+
content = msg.get("content")
41+
if not isinstance(content, str) or len(content) <= cap:
42+
continue
43+
if msg.get("role") == "system":
44+
continue
45+
original_len = len(content)
46+
msg["content"] = (
47+
content[: cap // 2]
48+
+ f"\n\n... [truncated {original_len - cap:,} chars] ...\n\n"
49+
+ content[-(cap // 2) :]
50+
)
51+
trimmed += 1
52+
if trimmed:
53+
logger.info(f"Capped {trimmed} oversized message(s) to {cap:,} chars each")
54+
return messages
55+
56+
57+
def truncate_messages(
58+
messages: List[Dict[str, Any]],
59+
keep_recent: int = 8,
60+
max_tokens_estimate: int = 120_000,
61+
) -> List[Dict[str, Any]]:
62+
"""Trim conversation history to fit within token budget.
63+
64+
Steps:
65+
1. Cap any single oversized message (via :func:`cap_message_content`).
66+
2. If total estimated tokens exceed *max_tokens_estimate*, keep only
67+
the system messages, the first user instruction, and the most
68+
recent *keep_recent* conversation rounds.
69+
70+
Args:
71+
messages: Full message list.
72+
keep_recent: Number of recent conversation rounds to preserve.
73+
max_tokens_estimate: Approximate token budget.
74+
75+
Returns:
76+
Possibly shortened message list.
77+
"""
78+
messages = cap_message_content(messages)
79+
80+
if len(messages) <= keep_recent + 2: # +2 for system and initial user
81+
return messages
82+
83+
total_text = json.dumps(messages, ensure_ascii=False)
84+
estimated_tokens = len(total_text) // 4
85+
86+
if estimated_tokens < max_tokens_estimate:
87+
return messages
88+
89+
logger.info(
90+
f"Truncating message history: {len(messages)} messages, "
91+
f"~{estimated_tokens:,} tokens -> keeping recent {keep_recent} rounds"
92+
)
93+
94+
system_messages: List[Dict[str, Any]] = []
95+
user_instruction = None
96+
conversation_messages: List[Dict[str, Any]] = []
97+
98+
for msg in messages:
99+
role = msg.get("role")
100+
if role == "system":
101+
system_messages.append(msg)
102+
elif role == "user" and user_instruction is None:
103+
user_instruction = msg
104+
else:
105+
conversation_messages.append(msg)
106+
107+
recent_messages = (
108+
conversation_messages[-(keep_recent * 2) :] if conversation_messages else []
109+
)
110+
111+
truncated = system_messages.copy()
112+
if user_instruction:
113+
truncated.append(user_instruction)
114+
truncated.extend(recent_messages)
115+
116+
logger.info(
117+
f"After truncation: {len(truncated)} messages, "
118+
f"~{len(json.dumps(truncated, ensure_ascii=False)) // 4:,} tokens (estimated)"
119+
)
120+
121+
return truncated

openspace/agents/grounding_agent.py

Lines changed: 20 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@
55
from typing import TYPE_CHECKING, Any, Dict, List, Optional
66

77
from openspace.agents.base import BaseAgent
8+
from openspace.agents.grounding.context import (
9+
clear_skill_context as _clear_skill_context,
10+
has_skill_context as _has_skill_context,
11+
set_skill_context as _set_skill_context,
12+
set_skill_registry as _set_skill_registry,
13+
)
14+
from openspace.agents.grounding.messages import (
15+
_MAX_SINGLE_CONTENT_CHARS,
16+
cap_message_content as _cap_message_content,
17+
truncate_messages as _truncate_messages_impl,
18+
)
819
from openspace.grounding.core.types import BackendType, ToolResult
920
from openspace.platforms.screenshot import ScreenshotClient
1021
from openspace.prompts import GroundingAgentPrompts
@@ -86,115 +97,32 @@ def set_skill_context(
8697
context: str,
8798
skill_ids: Optional[List[str]] = None,
8899
) -> None:
89-
"""Inject skill guidance into the agent's system prompt.
90-
91-
Called by ``OpenSpace.execute()`` before ``process()`` when skills
92-
are matched. The context is a formatted string built by
93-
``SkillRegistry.build_context_injection()``.
94-
95-
Args:
96-
context: Formatted skill content for system prompt injection.
97-
skill_ids: skill_id values of injected skills.
98-
"""
99-
self._skill_context = context if context else None
100-
self._active_skill_ids = skill_ids or []
101-
if self._skill_context:
102-
logger.info(f"Skill context set: {', '.join(self._active_skill_ids) or '(unnamed)'}")
100+
"""Inject skill guidance into the agent's system prompt."""
101+
return _set_skill_context(self, context, skill_ids)
103102

104103
def clear_skill_context(self) -> None:
105104
"""Remove skill guidance (used before fallback execution)."""
106-
if self._skill_context:
107-
logger.info(f"Skill context cleared (was: {', '.join(self._active_skill_ids)})")
108-
self._skill_context = None
109-
self._active_skill_ids = []
105+
return _clear_skill_context(self)
110106

111107
@property
112108
def has_skill_context(self) -> bool:
113-
return self._skill_context is not None
109+
return _has_skill_context(self)
114110

115111
def set_skill_registry(self, registry: Optional["SkillRegistry"]) -> None:
116112
"""Attach a SkillRegistry so the agent can offer ``retrieve_skill`` as a tool."""
117-
self._skill_registry = registry
118-
if registry:
119-
count = len(registry.list_skills())
120-
logger.info(f"Skill registry attached ({count} skill(s) available for mid-iteration retrieval)")
113+
return _set_skill_registry(self, registry)
121114

122-
_MAX_SINGLE_CONTENT_CHARS = 30_000
115+
_MAX_SINGLE_CONTENT_CHARS = _MAX_SINGLE_CONTENT_CHARS
123116

124117
@classmethod
125118
def _cap_message_content(cls, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
126-
"""Truncate oversized individual message contents in-place.
127-
128-
Targets tool-result messages and assistant messages that can
129-
carry enormous file contents (read_file on large CSVs/scripts).
130-
System messages and the first user instruction are never touched.
131-
"""
132-
cap = cls._MAX_SINGLE_CONTENT_CHARS
133-
trimmed = 0
134-
for msg in messages:
135-
content = msg.get("content")
136-
if not isinstance(content, str) or len(content) <= cap:
137-
continue
138-
if msg.get("role") == "system":
139-
continue
140-
original_len = len(content)
141-
msg["content"] = (
142-
content[: cap // 2]
143-
+ f"\n\n... [truncated {original_len - cap:,} chars] ...\n\n"
144-
+ content[-(cap // 2) :]
145-
)
146-
trimmed += 1
147-
if trimmed:
148-
logger.info(f"Capped {trimmed} oversized message(s) to {cap:,} chars each")
149-
return messages
119+
"""Truncate oversized individual message contents in-place."""
120+
return _cap_message_content(messages, cls._MAX_SINGLE_CONTENT_CHARS)
150121

151122
def _truncate_messages(
152123
self, messages: List[Dict[str, Any]], keep_recent: int = 8, max_tokens_estimate: int = 120000
153124
) -> List[Dict[str, Any]]:
154-
# First: cap any single oversized message to prevent one huge
155-
# tool-result from dominating the context window.
156-
messages = self._cap_message_content(messages)
157-
158-
if len(messages) <= keep_recent + 2: # +2 for system and initial user
159-
return messages
160-
161-
total_text = json.dumps(messages, ensure_ascii=False)
162-
estimated_tokens = len(total_text) // 4
163-
164-
if estimated_tokens < max_tokens_estimate:
165-
return messages
166-
167-
logger.info(
168-
f"Truncating message history: {len(messages)} messages, "
169-
f"~{estimated_tokens:,} tokens -> keeping recent {keep_recent} rounds"
170-
)
171-
172-
system_messages = []
173-
user_instruction = None
174-
conversation_messages = []
175-
176-
for msg in messages:
177-
role = msg.get("role")
178-
if role == "system":
179-
system_messages.append(msg)
180-
elif role == "user" and user_instruction is None:
181-
user_instruction = msg
182-
else:
183-
conversation_messages.append(msg)
184-
185-
recent_messages = conversation_messages[-(keep_recent * 2) :] if conversation_messages else []
186-
187-
truncated = system_messages.copy()
188-
if user_instruction:
189-
truncated.append(user_instruction)
190-
truncated.extend(recent_messages)
191-
192-
logger.info(
193-
f"After truncation: {len(truncated)} messages, "
194-
f"~{len(json.dumps(truncated, ensure_ascii=False)) // 4:,} tokens (estimated)"
195-
)
196-
197-
return truncated
125+
return _truncate_messages_impl(messages, keep_recent, max_tokens_estimate)
198126

199127
async def process(self, context: Dict[str, Any]) -> Dict[str, Any]:
200128
"""

0 commit comments

Comments
 (0)