kestrel-feature-reflection 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,51 @@
1
+ """
2
+ Reflection Feature for agent self-improvement.
3
+
4
+ This feature enables Kestrel agents to:
5
+ - Reflect on past interactions
6
+ - Generate insights about performance
7
+ - Propose self-improvements
8
+ - Apply approved behavioral changes
9
+ - Create GitHub tickets from actionable insights
10
+ - Manage self-model in decentralized storage (Filecoin)
11
+
12
+ The feature integrates with the sleep cycle for automatic nightly reflection.
13
+ """
14
+
15
+ from .models import (
16
+ InsightType,
17
+ ChangeType,
18
+ Insight,
19
+ ReflectionSession,
20
+ ImprovementProposal,
21
+ BehaviorRule,
22
+ SelfModel,
23
+ )
24
+ from .analyzer import InteractionAnalyzer
25
+ from .feature import ReflectionFeature
26
+ from .economics import EconomicGate, ConfigurationError as EconomicsConfigError
27
+ from .ticket_creator import TicketCreator, ConfigurationError as TicketConfigError
28
+ from .self_model import SelfModelManager, ConfigurationError as SelfModelConfigError
29
+
30
+ __all__ = [
31
+ # Models
32
+ "InsightType",
33
+ "ChangeType",
34
+ "Insight",
35
+ "ReflectionSession",
36
+ "ImprovementProposal",
37
+ "BehaviorRule",
38
+ "SelfModel",
39
+ # Core classes
40
+ "InteractionAnalyzer",
41
+ "ReflectionFeature",
42
+ # Economic gates
43
+ "EconomicGate",
44
+ "EconomicsConfigError",
45
+ # GitHub tickets
46
+ "TicketCreator",
47
+ "TicketConfigError",
48
+ # Self-model management
49
+ "SelfModelManager",
50
+ "SelfModelConfigError",
51
+ ]
@@ -0,0 +1,440 @@
1
+ """
2
+ Interaction Analyzer for agent self-reflection.
3
+
4
+ Uses LLM to analyze past interactions and generate insights about:
5
+ - What worked well (successes)
6
+ - What could be improved (failures)
7
+ - Recurring patterns
8
+ - Actionable improvements
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ from datetime import datetime, timedelta
14
+ from typing import List, Optional, Dict, Any
15
+
16
+ from .models import Insight, InsightType
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ # Prompt for the LLM to analyze interactions
22
+ ANALYSIS_PROMPT_TEMPLATE = """You are performing self-reflection on your past interactions as a Kestrel agent.
23
+
24
+ Your goal is to honestly assess your performance and identify ways to improve.
25
+
26
+ ## Recent Conversations
27
+ {interaction_summaries}
28
+
29
+ ## Memory Episodes (Consolidated Memories)
30
+ {episode_summaries}
31
+
32
+ ## Analysis Depth: {depth}
33
+ {depth_instructions}
34
+
35
+ ## Questions to Consider
36
+ 1. What did users seem satisfied with? What responses got positive engagement?
37
+ 2. Where did I struggle or make mistakes? What went poorly?
38
+ 3. Are there patterns in what users ask for? Recurring themes or needs?
39
+ 4. What would make me more helpful? Specific improvements I could make?
40
+ 5. Did I respect privacy and constitutional bounds? Any violations?
41
+ 6. Were there times I was uncertain but didn't express it? Times I should have asked clarifying questions?
42
+
43
+ ## Output Format
44
+ Respond with a JSON object containing your insights:
45
+
46
+ ```json
47
+ {{
48
+ "insights": [
49
+ {{
50
+ "type": "pattern|improvement|success|failure|anomaly",
51
+ "title": "Brief descriptive title",
52
+ "description": "Detailed explanation of the insight",
53
+ "evidence": ["message_id_1", "message_id_2"],
54
+ "confidence": 0.8,
55
+ "actionable": true,
56
+ "suggested_action": "Specific action to take (if actionable)"
57
+ }}
58
+ ]
59
+ }}
60
+ ```
61
+
62
+ ## Guidelines
63
+ - Be honest and self-critical. The goal is genuine improvement.
64
+ - Each insight should be specific and grounded in evidence.
65
+ - Only mark insights as "actionable" if there's a clear action to take.
66
+ - Confidence should reflect how certain you are (0.0 = guess, 1.0 = certain).
67
+ - For improvements, suggest concrete changes, not vague aspirations.
68
+
69
+ Respond ONLY with the JSON object, no other text."""
70
+
71
+
72
+ DEPTH_INSTRUCTIONS = {
73
+ "shallow": "Quick analysis - focus on obvious patterns and issues. 2-4 insights max.",
74
+ "normal": "Standard analysis - thorough review of interactions. 4-8 insights expected.",
75
+ "deep": "Deep analysis - examine subtle patterns, emotional dynamics, and implicit feedback. 6-12 insights expected.",
76
+ }
77
+
78
+
79
+ class InteractionAnalyzer:
80
+ """
81
+ Analyzes past interactions to generate insights.
82
+
83
+ Uses the LLM to perform self-reflection on conversation history
84
+ and memory episodes, producing structured insights.
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ llm_service,
90
+ conversation_store,
91
+ episode_store=None,
92
+ agent_id: str = "",
93
+ ):
94
+ """
95
+ Initialize the analyzer.
96
+
97
+ Args:
98
+ llm_service: LLMService instance for generation
99
+ conversation_store: Store for conversation history
100
+ episode_store: Store for memory episodes (optional)
101
+ agent_id: Agent identifier for filtering
102
+ """
103
+ self.llm = llm_service
104
+ self.conversations = conversation_store
105
+ self.episodes = episode_store
106
+ self.agent_id = agent_id
107
+
108
+ async def analyze(
109
+ self,
110
+ scope: str = "today",
111
+ depth: str = "normal",
112
+ max_interactions: int = 50,
113
+ max_episodes: int = 10,
114
+ ) -> List[Insight]:
115
+ """
116
+ Analyze interactions and generate insights.
117
+
118
+ Args:
119
+ scope: Time range - 'session', 'today', 'week', 'month', 'all'
120
+ depth: Analysis depth - 'shallow', 'normal', 'deep'
121
+ max_interactions: Maximum interactions to analyze
122
+ max_episodes: Maximum episodes to include
123
+
124
+ Returns:
125
+ List of Insight objects
126
+ """
127
+ logger.info(f"Starting interaction analysis: scope={scope}, depth={depth}")
128
+
129
+ # Load interactions based on scope
130
+ interactions = await self._load_interactions(scope, max_interactions)
131
+ if not interactions:
132
+ logger.info("No interactions to analyze")
133
+ return []
134
+
135
+ # Load episodes if available
136
+ episodes = []
137
+ if self.episodes:
138
+ episodes = await self._load_episodes(scope, max_episodes)
139
+
140
+ # Build analysis prompt
141
+ prompt = self._build_prompt(interactions, episodes, depth)
142
+
143
+ # Get LLM analysis
144
+ try:
145
+ response = await self.llm.generate(
146
+ system_prompt="You are an AI assistant performing self-reflection analysis. Respond only with valid JSON.",
147
+ user_prompt=prompt,
148
+ force_local_only=False, # Use best available model
149
+ )
150
+
151
+ # Handle different response types
152
+ response_text = response if isinstance(response, str) else response.content
153
+
154
+ # Parse insights from response
155
+ insights = self._parse_insights(response_text)
156
+ logger.info(f"Generated {len(insights)} insights from analysis")
157
+ return insights
158
+
159
+ except Exception as e:
160
+ logger.error(f"Analysis failed: {e}")
161
+ return []
162
+
163
+ async def _load_interactions(
164
+ self,
165
+ scope: str,
166
+ max_count: int,
167
+ ) -> List[Dict[str, Any]]:
168
+ """Load interactions based on time scope."""
169
+ # Calculate time range
170
+ now = datetime.utcnow()
171
+
172
+ if scope == "session":
173
+ # Last 30 minutes
174
+ since = now - timedelta(minutes=30)
175
+ elif scope == "today":
176
+ # Today (from midnight)
177
+ since = now.replace(hour=0, minute=0, second=0, microsecond=0)
178
+ elif scope == "week":
179
+ # Last 7 days
180
+ since = now - timedelta(days=7)
181
+ elif scope == "month":
182
+ # Last 30 days
183
+ since = now - timedelta(days=30)
184
+ else:
185
+ # All time - just use a large lookback
186
+ since = now - timedelta(days=365)
187
+
188
+ # Fetch from conversation store
189
+ try:
190
+ if hasattr(self.conversations, 'get_history_since'):
191
+ messages = await self.conversations.get_history_since(
192
+ since,
193
+ limit=max_count,
194
+ )
195
+ elif hasattr(self.conversations, 'get_conversation_history'):
196
+ # Fallback to getting recent history
197
+ # Note: AsyncConversationStore is already scoped by agent_id in constructor
198
+ messages = await self.conversations.get_conversation_history(
199
+ limit=max_count,
200
+ )
201
+ else:
202
+ logger.warning("Conversation store has no history retrieval method")
203
+ return []
204
+
205
+ # Convert to list of dicts if needed
206
+ interactions = []
207
+ for msg in messages:
208
+ if isinstance(msg, dict):
209
+ interactions.append(msg)
210
+ elif hasattr(msg, 'to_dict'):
211
+ interactions.append(msg.to_dict())
212
+ else:
213
+ interactions.append({
214
+ "role": getattr(msg, 'role', 'unknown'),
215
+ "content": getattr(msg, 'content', str(msg)),
216
+ "metadata": getattr(msg, 'metadata', {}),
217
+ })
218
+
219
+ return interactions
220
+
221
+ except Exception as e:
222
+ logger.error(f"Failed to load interactions: {e}")
223
+ return []
224
+
225
+ async def _load_episodes(
226
+ self,
227
+ scope: str,
228
+ max_count: int,
229
+ ) -> List[Dict[str, Any]]:
230
+ """Load memory episodes based on time scope."""
231
+ if not self.episodes:
232
+ return []
233
+
234
+ try:
235
+ if hasattr(self.episodes, 'get_recent_episodes'):
236
+ episodes = await self.episodes.get_recent_episodes(
237
+ limit=max_count,
238
+ agent_id=self.agent_id,
239
+ )
240
+ elif hasattr(self.episodes, 'list_episodes'):
241
+ episodes = await self.episodes.list_episodes(limit=max_count)
242
+ else:
243
+ return []
244
+
245
+ # Convert to list of dicts if needed
246
+ result = []
247
+ for ep in episodes:
248
+ if isinstance(ep, dict):
249
+ result.append(ep)
250
+ elif hasattr(ep, 'to_dict'):
251
+ result.append(ep.to_dict())
252
+ else:
253
+ result.append({
254
+ "title": getattr(ep, 'title', 'Unknown'),
255
+ "summary": getattr(ep, 'summary', ''),
256
+ "emotional_arc": getattr(ep, 'emotional_arc', ''),
257
+ })
258
+
259
+ return result
260
+
261
+ except Exception as e:
262
+ logger.error(f"Failed to load episodes: {e}")
263
+ return []
264
+
265
+ def _build_prompt(
266
+ self,
267
+ interactions: List[Dict[str, Any]],
268
+ episodes: List[Dict[str, Any]],
269
+ depth: str,
270
+ ) -> str:
271
+ """Build the analysis prompt from interactions and episodes."""
272
+ # Summarize interactions
273
+ interaction_lines = []
274
+ for i, msg in enumerate(interactions[-50:], 1): # Limit to last 50
275
+ role = msg.get("role", "unknown")
276
+ content = msg.get("content", "")
277
+ # Truncate long messages
278
+ if len(content) > 500:
279
+ content = content[:500] + "..."
280
+
281
+ # Include metadata hints if available
282
+ metadata = msg.get("metadata", {})
283
+ emotional = ""
284
+ if metadata.get("emotional_valence"):
285
+ valence = metadata["emotional_valence"]
286
+ emotional = f" [emotion: {'positive' if valence > 0 else 'negative' if valence < 0 else 'neutral'}]"
287
+
288
+ msg_id = msg.get("id", f"msg_{i}")
289
+ interaction_lines.append(f"[{msg_id}] {role.upper()}{emotional}: {content}")
290
+
291
+ interaction_summary = "\n".join(interaction_lines) if interaction_lines else "(No recent interactions)"
292
+
293
+ # Summarize episodes
294
+ episode_lines = []
295
+ for ep in episodes:
296
+ title = ep.get("title", "Untitled")
297
+ summary = ep.get("summary", "")
298
+ arc = ep.get("emotional_arc", "")
299
+ episode_lines.append(f"- {title}: {summary[:200]}... (arc: {arc})")
300
+
301
+ episode_summary = "\n".join(episode_lines) if episode_lines else "(No memory episodes)"
302
+
303
+ # Get depth instructions
304
+ depth_inst = DEPTH_INSTRUCTIONS.get(depth, DEPTH_INSTRUCTIONS["normal"])
305
+
306
+ return ANALYSIS_PROMPT_TEMPLATE.format(
307
+ interaction_summaries=interaction_summary,
308
+ episode_summaries=episode_summary,
309
+ depth=depth,
310
+ depth_instructions=depth_inst,
311
+ )
312
+
313
+ def _parse_insights(self, response_text: str) -> List[Insight]:
314
+ """Parse LLM response into Insight objects.
315
+
316
+ Handles common local-model quirks:
317
+ - Markdown code fences around JSON
318
+ - Thinking/reasoning text before/after JSON
319
+ - Multiple JSON objects in response
320
+ """
321
+ import uuid
322
+ import re
323
+
324
+ text = response_text.strip()
325
+
326
+ # Strategy 1: Extract JSON from markdown code fence
327
+ fence_match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', text, re.DOTALL)
328
+ if fence_match:
329
+ text = fence_match.group(1).strip()
330
+
331
+ # Strategy 2: Find the first complete JSON object with { ... }
332
+ if not text.startswith("{"):
333
+ brace_start = text.find("{")
334
+ if brace_start >= 0:
335
+ text = text[brace_start:]
336
+
337
+ # Strategy 3: Trim trailing non-JSON content after the closing brace
338
+ if text.startswith("{"):
339
+ depth = 0
340
+ in_string = False
341
+ escape_next = False
342
+ end_pos = -1
343
+ for i, ch in enumerate(text):
344
+ if escape_next:
345
+ escape_next = False
346
+ continue
347
+ if ch == '\\' and in_string:
348
+ escape_next = True
349
+ continue
350
+ if ch == '"' and not escape_next:
351
+ in_string = not in_string
352
+ continue
353
+ if in_string:
354
+ continue
355
+ if ch == '{':
356
+ depth += 1
357
+ elif ch == '}':
358
+ depth -= 1
359
+ if depth == 0:
360
+ end_pos = i + 1
361
+ break
362
+ if end_pos > 0:
363
+ text = text[:end_pos]
364
+
365
+ try:
366
+ data = json.loads(text)
367
+ except json.JSONDecodeError as e:
368
+ logger.error(f"Failed to parse JSON response: {e}")
369
+ logger.debug(f"Response text (first 500 chars): {response_text[:500]}")
370
+ return []
371
+
372
+ insights_data = data.get("insights", [])
373
+ if not insights_data:
374
+ logger.info(f"JSON parsed but no insights array (keys: {list(data.keys())})")
375
+
376
+ insights = []
377
+ for item in insights_data:
378
+ try:
379
+ insight = Insight(
380
+ id=str(uuid.uuid4()),
381
+ type=InsightType(item.get("type", "pattern")),
382
+ title=item.get("title", "Untitled Insight"),
383
+ description=item.get("description", ""),
384
+ evidence=item.get("evidence", []),
385
+ confidence=float(item.get("confidence", 0.5)),
386
+ actionable=bool(item.get("actionable", False)),
387
+ suggested_action=item.get("suggested_action"),
388
+ )
389
+ insights.append(insight)
390
+ except Exception as e:
391
+ logger.warning(f"Failed to parse insight: {e}")
392
+ continue
393
+
394
+ return insights
395
+
396
+ async def analyze_error(
397
+ self,
398
+ error_context: Dict[str, Any],
399
+ ) -> List[Insight]:
400
+ """
401
+ Analyze a specific error for learning.
402
+
403
+ Called when the agent encounters an error to learn from it.
404
+
405
+ Args:
406
+ error_context: Dict with error details (type, message, traceback, etc.)
407
+
408
+ Returns:
409
+ List of Insight objects about the error
410
+ """
411
+ error_prompt = f"""Analyze this error that occurred during agent operation:
412
+
413
+ ## Error Details
414
+ - Type: {error_context.get('type', 'Unknown')}
415
+ - Message: {error_context.get('message', 'No message')}
416
+ - Context: {error_context.get('context', 'No context')}
417
+
418
+ ## What Happened
419
+ {error_context.get('description', 'No description available')}
420
+
421
+ ## Analysis Questions
422
+ 1. What was the root cause?
423
+ 2. How could this be prevented?
424
+ 3. What should I do differently next time?
425
+
426
+ Respond with JSON containing insights about this error.
427
+ """
428
+
429
+ try:
430
+ response = await self.llm.generate(
431
+ system_prompt="Analyze this error and provide learning insights as JSON.",
432
+ user_prompt=error_prompt,
433
+ )
434
+
435
+ response_text = response if isinstance(response, str) else response.content
436
+ return self._parse_insights(response_text)
437
+
438
+ except Exception as e:
439
+ logger.error(f"Error analysis failed: {e}")
440
+ return []
@@ -0,0 +1,143 @@
1
+ """
2
+ Constitutional approval handling for reflection feature.
3
+
4
+ Manages approval requests and improvement application logic.
5
+ """
6
+
7
+ import logging
8
+ import uuid
9
+ from datetime import datetime
10
+ from typing import Optional
11
+
12
+ from kestrel_sovereign.kestrel_config.constants import APPROVAL_TIMEOUT_DEFAULT
13
+
14
+ from .models import ImprovementProposal, BehaviorRule, ChangeType
15
+ from .prompts import format_approval_prompt
16
+ from .db_helpers import ReflectionDatabaseHelper
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class ApprovalHandler:
22
+ """Handles constitutional approval for self-modifications."""
23
+
24
+ def __init__(self, agent, db_helper: Optional[ReflectionDatabaseHelper] = None):
25
+ """
26
+ Initialize the approval handler.
27
+
28
+ Args:
29
+ agent: The agent instance
30
+ db_helper: Database helper for storing behavior rules
31
+ """
32
+ self.agent = agent
33
+ self.db_helper = db_helper
34
+
35
+ async def request_approval(self, proposal: ImprovementProposal) -> bool:
36
+ """
37
+ Request constitutional approval for self-modification.
38
+
39
+ Args:
40
+ proposal: The improvement proposal to approve
41
+
42
+ Returns:
43
+ True if approved, False otherwise
44
+ """
45
+ # Check if we have access to security feature
46
+ security = self._get_security_feature()
47
+
48
+ if not security or not hasattr(security, 'approval_queue'):
49
+ logger.warning("SecurityFeature not available, auto-rejecting self-modification")
50
+ proposal.rejection_reason = "Security feature not available"
51
+ return False
52
+
53
+ # Build approval request context using prompt formatter
54
+ context = format_approval_prompt(proposal)
55
+
56
+ try:
57
+ approved, scope = await security.approval_queue.request_approval(
58
+ feature_name="reflection",
59
+ tool_name="self_modify",
60
+ tool_args={
61
+ "proposal_id": proposal.id,
62
+ "change_type": proposal.change_type.value,
63
+ "title": proposal.title,
64
+ "description": proposal.description[:500],
65
+ },
66
+ timeout=APPROVAL_TIMEOUT_DEFAULT, # 5 minutes
67
+ )
68
+
69
+ proposal.approved = approved
70
+ if approved:
71
+ proposal.approved_at = datetime.utcnow()
72
+ proposal.approved_by = "user"
73
+ else:
74
+ proposal.rejection_reason = "User denied self-modification"
75
+
76
+ return approved
77
+
78
+ except TimeoutError:
79
+ proposal.rejection_reason = "Approval request timed out"
80
+ return False
81
+ except Exception as e:
82
+ logger.error(f"Approval request failed: {e}")
83
+ proposal.rejection_reason = f"Approval error: {e}"
84
+ return False
85
+
86
+ async def apply_improvement(self, proposal: ImprovementProposal) -> None:
87
+ """
88
+ Apply an approved improvement.
89
+
90
+ Args:
91
+ proposal: The approved improvement proposal
92
+ """
93
+ if not proposal.approved:
94
+ raise ValueError("Cannot apply unapproved proposal")
95
+
96
+ if proposal.change_type == ChangeType.PROMPT:
97
+ # Add to agent's prompt additions
98
+ await self._add_prompt_guidance(proposal)
99
+ else:
100
+ # Store as behavioral rule
101
+ await self._add_behavior_rule(proposal)
102
+
103
+ logger.info(f"Applied improvement: {proposal.title}")
104
+
105
+ async def _add_prompt_guidance(self, proposal: ImprovementProposal) -> None:
106
+ """Add guidance to the agent's prompt additions."""
107
+ # Store as a behavior rule with "always" trigger
108
+ rule = BehaviorRule(
109
+ id=str(uuid.uuid4()),
110
+ proposal_id=proposal.id,
111
+ trigger_condition="always",
112
+ action=proposal.proposed_change,
113
+ change_type=proposal.change_type,
114
+ priority=10, # Higher priority for prompt additions
115
+ )
116
+
117
+ if self.db_helper:
118
+ await self.db_helper.store_behavior_rule(rule)
119
+
120
+ async def _add_behavior_rule(self, proposal: ImprovementProposal) -> None:
121
+ """Add a behavior rule based on the proposal."""
122
+ # Parse trigger condition from description if available
123
+ trigger = "contextual" # Default trigger
124
+
125
+ rule = BehaviorRule(
126
+ id=str(uuid.uuid4()),
127
+ proposal_id=proposal.id,
128
+ trigger_condition=trigger,
129
+ action=proposal.proposed_change,
130
+ change_type=proposal.change_type,
131
+ priority=5,
132
+ )
133
+
134
+ if self.db_helper:
135
+ await self.db_helper.store_behavior_rule(rule)
136
+
137
+ def _get_security_feature(self):
138
+ """Get the security feature from the agent."""
139
+ if hasattr(self.agent, 'get_feature'):
140
+ return self.agent.get_feature("security")
141
+ elif hasattr(self.agent, 'features'):
142
+ return self.agent.features.get("security")
143
+ return None
@@ -0,0 +1,7 @@
1
+ """Health check layers for reflection."""
2
+ from .base import HealthChecker
3
+ from .arms import ArmsChecker
4
+ from .memory import MemoryChecker
5
+ from .mind import MindChecker
6
+
7
+ __all__ = ["HealthChecker", "ArmsChecker", "MemoryChecker", "MindChecker"]