astraagent 2.25.6 → 2.26.0

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.
@@ -27,7 +27,7 @@ class AstraAgent:
27
27
  An elite AI agent more powerful than ChatGPT, Claude, or Gemini.
28
28
  """
29
29
 
30
- def __init__(self, config: AgentConfig = None):
30
+ def __init__(self, config: AgentConfig = None):
31
31
  self.config = config or AgentConfig()
32
32
  self.state = AgentState()
33
33
  self.logger = self._setup_logging()
@@ -45,9 +45,9 @@ class AstraAgent:
45
45
  self.tools = create_default_registry()
46
46
  self.llm: Optional[LLMProvider] = None
47
47
 
48
- # Message history
49
- self.messages: List[Message] = []
50
- self._format_retry_count: int = 0
48
+ # Message history
49
+ self.messages: List[Message] = []
50
+ self._format_retry_count: int = 0
51
51
 
52
52
  # Check for API key upfront
53
53
  self._validate_config()
@@ -105,9 +105,9 @@ class AstraAgent:
105
105
  api_base=self.config.llm.api_base
106
106
  )
107
107
 
108
- def _build_system_prompt(self, mode: str = "default", goal: Optional[str] = None) -> str:
109
- """Build the enhanced system prompt with context."""
110
- tool_names = ", ".join(self.tools.list_enabled())
108
+ def _build_system_prompt(self, mode: str = "default", goal: Optional[str] = None) -> str:
109
+ """Build the enhanced system prompt with context."""
110
+ tool_names = ", ".join(self.tools.list_enabled())
111
111
 
112
112
  # Build base prompt
113
113
  prompt = build_system_prompt(
@@ -116,39 +116,44 @@ class AstraAgent:
116
116
  mode=mode
117
117
  )
118
118
 
119
- # Add memory context if available
120
- try:
121
- user_facts = self.memory.get_user_facts()
122
- if user_facts:
123
- prompt += "\n\n=== KNOWN ABOUT USER ===\n"
124
- for fact in user_facts[:5]:
125
- prompt += f"- {fact.content}\n"
126
-
127
- # Add compact goal-aware memory context
128
- if goal:
129
- goal_context = self.memory.get_goal_context(goal, max_items=12)
130
- if goal_context:
131
- prompt += "\n\n=== MEMORY CONTEXT FOR CURRENT GOAL ===\n"
132
- prompt += goal_context[:3000]
133
- except (AttributeError, ValueError, KeyError) as e:
134
- self.logger.warning(f"Failed to retrieve user facts: {e}")
135
- except Exception as e:
136
- self.logger.error(f"Unexpected error retrieving user facts: {e}", exc_info=True)
137
-
138
- return prompt
139
-
140
- def _is_unstructured_final(self, raw_content: str, parsed: Dict[str, Any]) -> bool:
141
- """Detect plain-text final answers that violate required JSON format."""
142
- if "final" not in parsed:
143
- return False
144
- content = (raw_content or "").strip()
145
- if not content:
146
- return False
147
- if "```json" in content:
148
- return False
149
- if content.startswith("{") and content.endswith("}"):
150
- return False
151
- return True
119
+ # Add memory context if available
120
+ try:
121
+ user_facts = self.memory.get_user_facts()
122
+ if user_facts:
123
+ prompt += "\n\n=== KNOWN ABOUT USER ===\n"
124
+ for fact in user_facts[:5]:
125
+ prompt += f"- {fact.content}\n"
126
+
127
+ # Add compact goal-aware memory context
128
+ if goal:
129
+ goal_context = self.memory.get_goal_context(goal, max_items=12)
130
+ if goal_context:
131
+ prompt += "\n\n=== MEMORY CONTEXT FOR CURRENT GOAL ===\n"
132
+ prompt += goal_context[:3000]
133
+ except (AttributeError, ValueError, KeyError) as e:
134
+ self.logger.warning(f"Failed to retrieve user facts: {e}")
135
+ except Exception as e:
136
+ self.logger.error(f"Unexpected error retrieving user facts: {e}", exc_info=True)
137
+
138
+ return prompt
139
+
140
+ def _is_unstructured_final(self, raw_content: str, parsed: Dict[str, Any]) -> bool:
141
+ """Detect plain-text final answers that violate required JSON format."""
142
+ if "final" not in parsed:
143
+ return False
144
+ content = (raw_content or "").strip()
145
+ if not content:
146
+ return False
147
+ if "```json" in content:
148
+ return False
149
+ if content.startswith("{") and content.endswith("}"):
150
+ return False
151
+
152
+ # Don't force JSON for very short responses (likely greetings or simple confirmations)
153
+ if len(content) < 100 and not any(kw in content.lower() for kw in ["tool", "action", "exec", "run"]):
154
+ return False
155
+
156
+ return True
152
157
 
153
158
  def _parse_response(self, content: str) -> Optional[Dict[str, Any]]:
154
159
  """Parse response from LLM. Handles both JSON and plain text."""
@@ -244,30 +249,48 @@ class AstraAgent:
244
249
 
245
250
  return result
246
251
 
252
+ def _manage_history(self):
253
+ """Manage conversation history to prevent token bloat while keeping system instructions."""
254
+ if len(self.messages) <= 20:
255
+ return
256
+
257
+ # Keep system prompt, initial goal, and last context
258
+ preserved = []
259
+ if self.messages and self.messages[0].role == "system":
260
+ preserved.append(self.messages[0])
261
+
262
+ # Keep most recent messages (sliding window)
263
+ recent = self.messages[-12:]
264
+
265
+ # Merge - ensuring we don't duplicate the system prompt if it was in 'recent'
266
+ self.messages = preserved + [m for m in recent if m.role != "system"]
267
+ self.logger.debug(f"Managed history: reduced to {len(self.messages)} messages")
268
+
247
269
  async def _think(self, goal: str) -> LLMResponse:
248
270
  """Generate next action from LLM."""
249
271
  self._init_llm()
272
+ self._manage_history()
250
273
 
251
- # Update messages if needed
252
- if not self.messages:
253
- self.messages.append(Message(role="system", content=self._build_system_prompt(goal=goal)))
254
- self.messages.append(Message(role="user", content=f"Goal: {goal}"))
255
-
256
- # Add memory context as explicit runtime instruction message
257
- try:
258
- memory_context = self.memory.get_goal_context(goal, max_items=10)
259
- if memory_context:
260
- self.messages.append(
261
- Message(
262
- role="user",
263
- content=(
264
- "Memory context (must be considered while planning):\n"
265
- f"{memory_context[:2500]}"
266
- )
267
- )
268
- )
269
- except Exception as e:
270
- self.logger.warning(f"Failed to attach memory context: {e}")
274
+ # Update messages if needed
275
+ if not self.messages:
276
+ self.messages.append(Message(role="system", content=self._build_system_prompt(mode=self.config.prompt_mode, goal=goal)))
277
+ self.messages.append(Message(role="user", content=f"Goal: {goal}"))
278
+
279
+ # Add memory context as explicit runtime instruction message
280
+ try:
281
+ memory_context = self.memory.get_goal_context(goal, max_items=10)
282
+ if memory_context:
283
+ self.messages.append(
284
+ Message(
285
+ role="user",
286
+ content=(
287
+ "Memory context (must be considered while planning):\n"
288
+ f"{memory_context[:2500]}"
289
+ )
290
+ )
291
+ )
292
+ except Exception as e:
293
+ self.logger.warning(f"Failed to attach memory context: {e}")
271
294
 
272
295
  # Add context about recent actions
273
296
  if self.state.action_history:
@@ -282,12 +305,25 @@ class AstraAgent:
282
305
  if len(self.messages) <= 2:
283
306
  self.messages.append(Message(role="assistant", content=context))
284
307
 
285
- response = await self.llm.generate(
286
- self.messages,
287
- tools=self.tools.get_all_schemas(),
288
- temperature=self.config.llm.temperature,
289
- max_tokens=self.config.llm.max_tokens
290
- )
308
+ # Retry logic for rate limits
309
+ retry_delay = 5
310
+ for attempt in range(3):
311
+ try:
312
+ response = await self.llm.generate(
313
+ self.messages,
314
+ tools=self.tools.get_all_schemas(),
315
+ temperature=self.config.llm.temperature,
316
+ max_tokens=self.config.llm.max_tokens
317
+ )
318
+ break
319
+ except RuntimeError as e:
320
+ err = str(e)
321
+ if ("429" in err or "rate limit" in err.lower()) and attempt < 2:
322
+ self.logger.warning(f"Rate limit hit. Retrying in {retry_delay}s...")
323
+ await asyncio.sleep(retry_delay)
324
+ retry_delay *= 2
325
+ continue
326
+ raise e
291
327
 
292
328
  self.state.total_tokens_used += response.tokens_used
293
329
  return response
@@ -302,6 +338,12 @@ class AstraAgent:
302
338
  self.logger.warning("Max iterations reached")
303
339
  return True
304
340
 
341
+ # Check token usage safety
342
+ if self.state.total_tokens_used > self.config.safety.max_session_tokens:
343
+ self.logger.warning(f"Token limit reached ({self.state.total_tokens_used}). Stopping for safety.")
344
+ self.state.last_error = "Token usage threshold exceeded."
345
+ return True
346
+
305
347
  # Get LLM response
306
348
  try:
307
349
  response = await self._think(self.state.current_goal)
@@ -316,11 +358,16 @@ class AstraAgent:
316
358
  return False
317
359
 
318
360
  # Handle tool calls from LLM
319
- if response.tool_calls:
320
- if response.content:
321
- self.memory.remember_conversation("assistant", response.content, metadata={"kind": "tool_call"})
322
- for tc in response.tool_calls:
323
- action = Action(
361
+ if response.tool_calls:
362
+ self.messages.append(Message(
363
+ role="assistant",
364
+ content=response.content or "",
365
+ tool_calls=response.tool_calls
366
+ ))
367
+ if response.content:
368
+ self.memory.remember_conversation("assistant", response.content, metadata={"kind": "tool_call"})
369
+ for tc in response.tool_calls:
370
+ action = Action(
324
371
  tool=tc["name"],
325
372
  args=tc["arguments"],
326
373
  thought=response.content
@@ -353,35 +400,35 @@ class AstraAgent:
353
400
  self.messages.append(Message(role="assistant", content=response.content))
354
401
  return False
355
402
 
356
- # Check for final response
357
- if "final" in parsed:
358
- if self._is_unstructured_final(response.content, parsed) and self._format_retry_count < 2:
359
- self._format_retry_count += 1
360
- self.messages.append(Message(
361
- role="user",
362
- content=(
363
- "FORMAT ERROR: Reply ONLY with valid JSON in the required schema. "
364
- "Do not output plain text."
365
- )
366
- ))
367
- return False
368
-
369
- self._format_retry_count = 0
370
- self.logger.debug(f"Task complete: {parsed['final']}")
371
- self.memory.remember_conversation("assistant", str(parsed["final"]))
372
- return True
403
+ # Check for final response
404
+ if "final" in parsed:
405
+ if self._is_unstructured_final(response.content, parsed) and self._format_retry_count < 2:
406
+ self._format_retry_count += 1
407
+ self.messages.append(Message(
408
+ role="user",
409
+ content=(
410
+ "FORMAT ERROR: Reply ONLY with valid JSON in the required schema. "
411
+ "Do not output plain text."
412
+ )
413
+ ))
414
+ return False
415
+
416
+ self._format_retry_count = 0
417
+ self.logger.debug(f"Task complete: {parsed['final']}")
418
+ self.memory.remember_conversation("assistant", str(parsed["final"]))
419
+ return True
373
420
 
374
421
  # Check for action
375
- if "action" in parsed:
376
- self._format_retry_count = 0
377
- action = Action(
378
- tool=parsed["action"],
379
- args=parsed.get("args", {}),
380
- thought=parsed.get("thought", "")
381
- )
382
- if action.thought:
383
- self.memory.remember_conversation("assistant", action.thought, metadata={"kind": "reasoning"})
384
- action.mark_executing()
422
+ if "action" in parsed:
423
+ self._format_retry_count = 0
424
+ action = Action(
425
+ tool=parsed["action"],
426
+ args=parsed.get("args", {}),
427
+ thought=parsed.get("thought", "")
428
+ )
429
+ if action.thought:
430
+ self.memory.remember_conversation("assistant", action.thought, metadata={"kind": "reasoning"})
431
+ action.mark_executing()
385
432
 
386
433
  result = await self._execute_tool(action.tool, action.args)
387
434
 
@@ -401,11 +448,11 @@ class AstraAgent:
401
448
 
402
449
  # Add action result to messages
403
450
  self.messages.append(Message(role="assistant", content=json.dumps(parsed)))
404
- self.messages.append(Message(
405
- role="user",
406
- content=f"Tool result: {result.output or result.error}"
407
- ))
408
- return False
451
+ self.messages.append(Message(
452
+ role="user",
453
+ content=f"Tool result: {result.output or result.error}"
454
+ ))
455
+ return False
409
456
 
410
457
  # No clear action, continue
411
458
  self.messages.append(Message(role="assistant", content=response.content))
@@ -420,20 +467,20 @@ class AstraAgent:
420
467
  self.logger.debug(f"Configuration error: {e}")
421
468
  return f"Failed to start: {e}"
422
469
 
423
- self.logger.debug(f"Starting AstraAgent with goal: {goal}")
424
- self.state.set_goal(goal)
425
- self.state.is_running = True
426
- self.messages = [] # Reset messages
427
- self._format_retry_count = 0
470
+ self.logger.debug(f"Starting AstraAgent with goal: {goal}")
471
+ self.state.set_goal(goal)
472
+ self.state.is_running = True
473
+ self.messages = [] # Reset messages
474
+ self._format_retry_count = 0
428
475
 
429
476
  # Remember the goal
430
- self.memory.remember(
431
- content=f"User goal: {goal}",
432
- memory_type="conversation",
433
- importance=0.8,
434
- tags=["goal", "user_request"]
435
- )
436
- self.memory.remember_conversation("user", goal, metadata={"kind": "goal"})
477
+ self.memory.remember(
478
+ content=f"User goal: {goal}",
479
+ memory_type="conversation",
480
+ importance=0.8,
481
+ tags=["goal", "user_request"]
482
+ )
483
+ self.memory.remember_conversation("user", goal, metadata={"kind": "goal"})
437
484
 
438
485
  try:
439
486
  while not await self.step():
@@ -53,21 +53,35 @@ class LLMConfig:
53
53
 
54
54
  def __post_init__(self):
55
55
  # Try to get API key from environment if not provided
56
- if not self.api_key:
57
- self.api_key = os.getenv("LOCAL_API_KEY")
58
56
  if not self.api_base:
59
57
  self.api_base = os.getenv("LOCAL_API_BASE") or "http://localhost:8000/api/v1"
58
+
59
+ if not self.api_key:
60
+ # Check provider specific keys first
61
+ if self.provider == "openai":
62
+ self.api_key = os.getenv("OPENAI_API_KEY")
63
+ elif self.provider == "gemini":
64
+ self.api_key = os.getenv("GEMINI_API_KEY")
65
+ elif self.provider == "anthropic":
66
+ self.api_key = os.getenv("ANTHROPIC_API_KEY")
67
+ elif self.provider == "openrouter":
68
+ self.api_key = os.getenv("OPENROUTER_API_KEY")
69
+ elif self.provider == "groq":
70
+ self.api_key = os.getenv("GROQ_API_KEY")
71
+ else:
72
+ self.api_key = os.getenv("LOCAL_API_KEY")
60
73
 
61
74
  def validate(self) -> None:
62
75
  """Validate configuration is complete."""
76
+ # Local provider often doesn't need a key (e.g. Ollama)
77
+ if self.provider == "local":
78
+ return
79
+
63
80
  if not self.api_key or not self.api_key.strip():
64
81
  raise ValueError(
65
- "LOCAL_API_KEY is not set. Please:\n"
66
- " 1. Copy .env.template to .env\n"
67
- " 2. Set LOCAL_API_KEY=your-key in .env\n"
68
- " Or set environment variable:\n"
69
- f" Windows: set LOCAL_API_KEY=your-key\n"
70
- f" Linux/Mac: export LOCAL_API_KEY=your-key"
82
+ f"API key for provider '{self.provider}' is not set.\n"
83
+ "Please run 'astra settings' locally to configure it.\n"
84
+ f"Or set the environment variable (e.g., {self.provider.upper()}_API_KEY)."
71
85
  )
72
86
 
73
87
 
@@ -124,6 +138,7 @@ class SafetyConfig:
124
138
  allowed_domains: List[str] = field(default_factory=list) # Empty = all allowed
125
139
  max_file_size_mb: int = 100
126
140
  enable_content_filter: bool = True
141
+ max_session_tokens: int = 50000 # Prevent runaway costs
127
142
 
128
143
 
129
144
  @dataclass
@@ -144,11 +159,12 @@ class AgentConfig:
144
159
  """Master configuration for AstraAgent."""
145
160
  # Identity
146
161
  name: str = "AstraAgent"
147
- version: str = "1.0.0"
162
+ version: str = "2.26.0"
148
163
  description: str = "Autonomous AI Agent"
149
164
 
150
165
  # Execution
151
166
  mode: ExecutionMode = ExecutionMode.AUTONOMOUS
167
+ prompt_mode: str = "default" # "default", "engineer", "research", "code", etc.
152
168
  max_iterations: int = 50
153
169
  reflection_enabled: bool = True
154
170
  planning_enabled: bool = True
@@ -335,86 +335,86 @@ class UnifiedMemorySystem:
335
335
  metadata={"action": action, "result": result, "success": success}
336
336
  )
337
337
 
338
- def learn(self, knowledge: str, source: str = "inference") -> MemoryItem:
339
- """Add to knowledge base."""
340
- return self.remember(
341
- content=knowledge,
342
- memory_type="knowledge",
343
- importance=0.7,
344
- tags=["knowledge", source],
345
- source=source
346
- )
347
-
348
- # Backward-compatibility and convenience API
349
- def store(
350
- self,
351
- content: str,
352
- memory_type: str = "long_term",
353
- importance: float = 0.5,
354
- tags: List[str] = None
355
- ) -> MemoryItem:
356
- """
357
- Store memory with legacy memory types.
358
-
359
- Legacy memory types:
360
- - short_term -> conversation
361
- - long_term -> knowledge
362
- - semantic -> fact
363
- """
364
- type_map = {
365
- "short_term": "conversation",
366
- "long_term": "knowledge",
367
- "semantic": "fact",
368
- "conversation": "conversation",
369
- "knowledge": "knowledge",
370
- "fact": "fact",
371
- "action": "action",
372
- "observation": "observation",
373
- }
374
- mapped_type = type_map.get(memory_type, "knowledge")
375
- return self.remember(
376
- content=content,
377
- memory_type=mapped_type,
378
- importance=importance,
379
- tags=tags or [],
380
- source="store_api"
381
- )
382
-
383
- def get_recent(self, n: int = 20) -> List[MemoryItem]:
384
- """Return recent memories across all stores."""
385
- with self._lock:
386
- combined = (
387
- list(self.conversations.values())
388
- + list(self.user_facts.values())
389
- + list(self.knowledge.values())
390
- + list(self.actions.values())
391
- + list(self.observations.values())
392
- )
393
- combined.sort(key=lambda x: x.created_at, reverse=True)
394
- return combined[:n]
395
-
396
- def get_goal_context(self, goal: str, max_items: int = 15) -> str:
397
- """Get compact memory context relevant to a specific goal."""
398
- if not goal:
399
- return self.get_context_summary(max_items=max_items)
400
-
401
- lines: List[str] = []
402
-
403
- # Most important user facts first
404
- facts = self.get_user_facts()[:5]
405
- if facts:
406
- lines.append("Known facts:")
407
- for item in facts:
408
- lines.append(f"- {item.content[:180]}")
409
-
410
- # Relevant memories by search
411
- relevant = self.search(query=goal, limit=max_items)
412
- if relevant:
413
- lines.append("Relevant memory:")
414
- for item in relevant:
415
- lines.append(f"- [{item.memory_type}] {item.content[:200]}")
416
-
417
- return "\n".join(lines).strip()
338
+ def learn(self, knowledge: str, source: str = "inference") -> MemoryItem:
339
+ """Add to knowledge base."""
340
+ return self.remember(
341
+ content=knowledge,
342
+ memory_type="knowledge",
343
+ importance=0.7,
344
+ tags=["knowledge", source],
345
+ source=source
346
+ )
347
+
348
+ # Backward-compatibility and convenience API
349
+ def store(
350
+ self,
351
+ content: str,
352
+ memory_type: str = "long_term",
353
+ importance: float = 0.5,
354
+ tags: List[str] = None
355
+ ) -> MemoryItem:
356
+ """
357
+ Store memory with legacy memory types.
358
+
359
+ Legacy memory types:
360
+ - short_term -> conversation
361
+ - long_term -> knowledge
362
+ - semantic -> fact
363
+ """
364
+ type_map = {
365
+ "short_term": "conversation",
366
+ "long_term": "knowledge",
367
+ "semantic": "fact",
368
+ "conversation": "conversation",
369
+ "knowledge": "knowledge",
370
+ "fact": "fact",
371
+ "action": "action",
372
+ "observation": "observation",
373
+ }
374
+ mapped_type = type_map.get(memory_type, "knowledge")
375
+ return self.remember(
376
+ content=content,
377
+ memory_type=mapped_type,
378
+ importance=importance,
379
+ tags=tags or [],
380
+ source="store_api"
381
+ )
382
+
383
+ def get_recent(self, n: int = 20) -> List[MemoryItem]:
384
+ """Return recent memories across all stores."""
385
+ with self._lock:
386
+ combined = (
387
+ list(self.conversations.values())
388
+ + list(self.user_facts.values())
389
+ + list(self.knowledge.values())
390
+ + list(self.actions.values())
391
+ + list(self.observations.values())
392
+ )
393
+ combined.sort(key=lambda x: x.created_at, reverse=True)
394
+ return combined[:n]
395
+
396
+ def get_goal_context(self, goal: str, max_items: int = 15) -> str:
397
+ """Get compact memory context relevant to a specific goal."""
398
+ if not goal:
399
+ return self.get_context_summary(max_items=max_items)
400
+
401
+ lines: List[str] = []
402
+
403
+ # Most important user facts first
404
+ facts = self.get_user_facts()[:5]
405
+ if facts:
406
+ lines.append("Known facts:")
407
+ for item in facts:
408
+ lines.append(f"- {item.content[:180]}")
409
+
410
+ # Relevant memories by search
411
+ relevant = self.search(query=goal, limit=max_items)
412
+ if relevant:
413
+ lines.append("Relevant memory:")
414
+ for item in relevant:
415
+ lines.append(f"- [{item.memory_type}] {item.content[:200]}")
416
+
417
+ return "\n".join(lines).strip()
418
418
 
419
419
  # ===========================================
420
420
  # RECALL & SEARCH
@@ -498,11 +498,16 @@ class UnifiedMemorySystem:
498
498
  continue
499
499
 
500
500
  results.append(item)
501
- item.access()
502
501
 
503
502
  # Sort by relevance
504
503
  results.sort(key=lambda x: x.relevance_score, reverse=True)
505
- return results[:limit]
504
+ top_results = results[:limit]
505
+
506
+ # Update access tracking for returned items
507
+ for item in top_results:
508
+ item.access()
509
+
510
+ return top_results
506
511
 
507
512
  def get_user_facts(self, category: str = None) -> List[MemoryItem]:
508
513
  """Get all user facts, optionally filtered by category."""