agent-runtime-core 0.7.0__py3-none-any.whl → 0.7.1__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.
Files changed (32) hide show
  1. agent_runtime_core/__init__.py +108 -1
  2. agent_runtime_core/agentic_loop.py +254 -0
  3. agent_runtime_core/config.py +54 -4
  4. agent_runtime_core/config_schema.py +307 -0
  5. agent_runtime_core/interfaces.py +106 -0
  6. agent_runtime_core/json_runtime.py +509 -0
  7. agent_runtime_core/llm/__init__.py +80 -7
  8. agent_runtime_core/llm/anthropic.py +133 -12
  9. agent_runtime_core/llm/models_config.py +180 -0
  10. agent_runtime_core/memory/__init__.py +70 -0
  11. agent_runtime_core/memory/manager.py +554 -0
  12. agent_runtime_core/memory/mixin.py +294 -0
  13. agent_runtime_core/multi_agent.py +569 -0
  14. agent_runtime_core/persistence/__init__.py +2 -0
  15. agent_runtime_core/persistence/file.py +277 -0
  16. agent_runtime_core/rag/__init__.py +65 -0
  17. agent_runtime_core/rag/chunking.py +224 -0
  18. agent_runtime_core/rag/indexer.py +253 -0
  19. agent_runtime_core/rag/retriever.py +261 -0
  20. agent_runtime_core/runner.py +193 -15
  21. agent_runtime_core/tool_calling_agent.py +88 -130
  22. agent_runtime_core/tools.py +179 -0
  23. agent_runtime_core/vectorstore/__init__.py +193 -0
  24. agent_runtime_core/vectorstore/base.py +138 -0
  25. agent_runtime_core/vectorstore/embeddings.py +242 -0
  26. agent_runtime_core/vectorstore/sqlite_vec.py +328 -0
  27. agent_runtime_core/vectorstore/vertex.py +295 -0
  28. {agent_runtime_core-0.7.0.dist-info → agent_runtime_core-0.7.1.dist-info}/METADATA +202 -1
  29. agent_runtime_core-0.7.1.dist-info/RECORD +57 -0
  30. agent_runtime_core-0.7.0.dist-info/RECORD +0 -39
  31. {agent_runtime_core-0.7.0.dist-info → agent_runtime_core-0.7.1.dist-info}/WHEEL +0 -0
  32. {agent_runtime_core-0.7.0.dist-info → agent_runtime_core-0.7.1.dist-info}/licenses/LICENSE +0 -0
@@ -8,14 +8,17 @@ The runner handles:
8
8
  - Managing state and checkpoints
9
9
  - Handling errors and retries
10
10
  - Cancellation
11
+ - Loading conversation history (enabled by default)
12
+ - Persisting messages after runs (enabled by default)
11
13
  """
12
14
 
13
15
  import asyncio
16
+ import logging
14
17
  import traceback
15
18
  from dataclasses import dataclass, field
16
19
  from datetime import datetime, timezone
17
20
  from typing import Optional
18
- from uuid import UUID
21
+ from uuid import UUID, uuid4
19
22
 
20
23
  from agent_runtime_core.config import get_config
21
24
  from agent_runtime_core.events.base import EventBus
@@ -30,6 +33,16 @@ from agent_runtime_core.interfaces import (
30
33
  from agent_runtime_core.queue.base import RunQueue, QueuedRun
31
34
  from agent_runtime_core.registry import get_runtime
32
35
  from agent_runtime_core.state.base import StateStore
36
+ from agent_runtime_core.persistence import (
37
+ PersistenceManager,
38
+ ConversationStore,
39
+ Conversation,
40
+ ConversationMessage,
41
+ ToolCall,
42
+ get_persistence_manager,
43
+ )
44
+
45
+ logger = logging.getLogger(__name__)
33
46
 
34
47
 
35
48
  @dataclass
@@ -127,26 +140,31 @@ class RunContextImpl:
127
140
  class AgentRunner:
128
141
  """
129
142
  Runs agent executions with full lifecycle management.
130
-
143
+
131
144
  The runner:
132
145
  1. Claims runs from the queue
133
146
  2. Looks up the appropriate runtime
134
- 3. Executes the runtime with a context
135
- 4. Handles errors, retries, and cancellation
136
- 5. Emits events throughout
147
+ 3. Loads conversation history (enabled by default)
148
+ 4. Executes the runtime with a context
149
+ 5. Handles errors, retries, and cancellation
150
+ 6. Persists messages after successful runs
151
+ 7. Emits events throughout
137
152
  """
138
-
153
+
139
154
  def __init__(
140
155
  self,
141
156
  queue: RunQueue,
142
157
  event_bus: EventBus,
143
158
  state_store: StateStore,
144
159
  config: Optional[RunnerConfig] = None,
160
+ persistence_manager: Optional[PersistenceManager] = None,
145
161
  ):
146
162
  self.queue = queue
147
163
  self.event_bus = event_bus
148
164
  self.state_store = state_store
149
165
  self.config = config or RunnerConfig()
166
+ self._persistence = persistence_manager or get_persistence_manager()
167
+ self._runtime_config = get_config()
150
168
  self._running = False
151
169
  self._current_run: Optional[UUID] = None
152
170
 
@@ -205,9 +223,9 @@ class AgentRunner:
205
223
 
206
224
  # Update status
207
225
  await self.state_store.update_run_status(run_id, "running")
208
-
209
- # Build context
210
- ctx = self._build_context(queued_run)
226
+
227
+ # Build context (loads conversation history if enabled)
228
+ ctx = await self._build_context(queued_run)
211
229
 
212
230
  # Emit started event
213
231
  await ctx.emit(EventType.RUN_STARTED, {
@@ -246,14 +264,25 @@ class AgentRunner:
246
264
  finally:
247
265
  self._current_run = None
248
266
 
249
- def _build_context(self, queued_run: QueuedRun) -> RunContextImpl:
267
+ async def _build_context(self, queued_run: QueuedRun) -> RunContextImpl:
250
268
  """Build a RunContext for a queued run."""
251
269
  input_data = queued_run.input
252
-
270
+ new_messages = input_data.get("messages", [])
271
+ conversation_id = input_data.get("conversation_id")
272
+
273
+ # Parse conversation_id if it's a string
274
+ if conversation_id and isinstance(conversation_id, str):
275
+ conversation_id = UUID(conversation_id)
276
+
277
+ # Load conversation history if enabled
278
+ messages = await self._load_conversation_history(
279
+ conversation_id, new_messages
280
+ )
281
+
253
282
  return RunContextImpl(
254
283
  run_id=queued_run.run_id,
255
- conversation_id=input_data.get("conversation_id"),
256
- input_messages=input_data.get("messages", []),
284
+ conversation_id=conversation_id,
285
+ input_messages=messages,
257
286
  params=input_data.get("params", {}),
258
287
  metadata=queued_run.metadata,
259
288
  tool_registry=ToolRegistry(), # TODO: Load from config
@@ -261,6 +290,90 @@ class AgentRunner:
261
290
  state_store=self.state_store,
262
291
  queue=self.queue,
263
292
  )
293
+
294
+ async def _load_conversation_history(
295
+ self,
296
+ conversation_id: Optional[UUID],
297
+ new_messages: list[Message],
298
+ ) -> list[Message]:
299
+ """
300
+ Load conversation history and prepend to new messages.
301
+
302
+ This enables multi-turn conversations by default. When a run is part of
303
+ a conversation, previous messages from that conversation are automatically
304
+ included in the context.
305
+
306
+ Args:
307
+ conversation_id: The conversation ID (if any)
308
+ new_messages: The new messages for this run
309
+
310
+ Returns:
311
+ Combined list of history + new messages
312
+ """
313
+ # Check if conversation history is enabled
314
+ if not self._runtime_config.include_conversation_history:
315
+ logger.debug("Conversation history disabled by config")
316
+ return new_messages
317
+
318
+ if not conversation_id:
319
+ logger.debug("No conversation_id, skipping history load")
320
+ return new_messages
321
+
322
+ try:
323
+ # Get conversation from persistence
324
+ conversation = await self._persistence.conversations.get(conversation_id)
325
+
326
+ if conversation is None:
327
+ logger.debug(f"Conversation {conversation_id} not found")
328
+ return new_messages
329
+
330
+ # Convert stored messages to the Message format
331
+ history: list[Message] = []
332
+ for msg in conversation.messages:
333
+ message_dict: Message = {
334
+ "role": msg.role,
335
+ "content": msg.content,
336
+ }
337
+
338
+ # Include tool_calls for assistant messages
339
+ if msg.tool_calls:
340
+ message_dict["tool_calls"] = [
341
+ {
342
+ "id": tc.id,
343
+ "type": "function",
344
+ "function": {
345
+ "name": tc.name,
346
+ "arguments": tc.arguments if isinstance(tc.arguments, str) else str(tc.arguments),
347
+ }
348
+ }
349
+ for tc in msg.tool_calls
350
+ ]
351
+
352
+ # Include tool_call_id for tool messages
353
+ if msg.tool_call_id:
354
+ message_dict["tool_call_id"] = msg.tool_call_id
355
+
356
+ history.append(message_dict)
357
+
358
+ if not history:
359
+ logger.debug("No conversation history found")
360
+ return new_messages
361
+
362
+ # Apply message limit if configured
363
+ max_messages = self._runtime_config.max_history_messages
364
+ if max_messages and len(history) > max_messages:
365
+ logger.debug(f"Limiting history from {len(history)} to {max_messages} messages")
366
+ history = history[-max_messages:]
367
+
368
+ logger.debug(f"Loaded {len(history)} history messages for conversation {conversation_id}")
369
+
370
+ # Combine history with new messages
371
+ return history + new_messages
372
+
373
+ except Exception as e:
374
+ logger.warning(f"Failed to load conversation history: {e}")
375
+ # Fall back to just new messages on error
376
+ return new_messages
264
377
 
265
378
  async def _heartbeat_loop(self, run_id: UUID) -> None:
266
379
  """Send periodic heartbeats to extend the lease."""
@@ -293,18 +406,83 @@ class AgentRunner:
293
406
  ) -> None:
294
407
  """Handle successful run completion."""
295
408
  await self.state_store.update_run_status(queued_run.run_id, "succeeded")
296
-
409
+
410
+ # Persist messages to conversation store if enabled
411
+ if self._runtime_config.auto_persist_messages and ctx.conversation_id:
412
+ await self._persist_messages(ctx.conversation_id, result.final_messages)
413
+
297
414
  await ctx.emit(EventType.RUN_SUCCEEDED, {
298
415
  "final_output": result.final_output,
299
416
  "usage": result.usage,
300
417
  })
301
-
418
+
302
419
  await self.queue.release(
303
420
  run_id=queued_run.run_id,
304
421
  worker_id=self.config.worker_id,
305
422
  success=True,
306
423
  output=result.final_output,
307
424
  )
425
+
426
+ async def _persist_messages(
427
+ self,
428
+ conversation_id: UUID,
429
+ messages: list[Message],
430
+ ) -> None:
431
+ """
432
+ Persist messages to the conversation store.
433
+
434
+ This saves the final_messages from a run to enable conversation history
435
+ for future runs.
436
+
437
+ Args:
438
+ conversation_id: The conversation to save to
439
+ messages: The messages to persist (typically result.final_messages)
440
+ """
441
+ try:
442
+ # Get or create conversation
443
+ conversation = await self._persistence.conversations.get(conversation_id)
444
+
445
+ if conversation is None:
446
+ # Create new conversation
447
+ conversation = Conversation(
448
+ id=conversation_id,
449
+ title="",
450
+ messages=[],
451
+ )
452
+
453
+ # Convert messages to ConversationMessage format
454
+ for msg in messages:
455
+ # Skip system messages - they're added by the agent each run
456
+ if msg.get("role") == "system":
457
+ continue
458
+
459
+ # Parse tool_calls if present
460
+ tool_calls = []
461
+ if msg.get("tool_calls"):
462
+ for tc in msg["tool_calls"]:
463
+ tool_calls.append(ToolCall(
464
+ id=tc.get("id", ""),
465
+ name=tc.get("function", {}).get("name", ""),
466
+ arguments=tc.get("function", {}).get("arguments", "{}"),
467
+ timestamp=datetime.now(timezone.utc),
468
+ ))
469
+
470
+ conv_message = ConversationMessage(
471
+ id=uuid4(),
472
+ role=msg.get("role", "user"),
473
+ content=msg.get("content", ""),
474
+ timestamp=datetime.now(timezone.utc),
475
+ tool_calls=tool_calls,
476
+ tool_call_id=msg.get("tool_call_id"),
477
+ )
478
+ conversation.messages.append(conv_message)
479
+
480
+ # Save conversation
481
+ await self._persistence.conversations.save(conversation)
482
+ logger.debug(f"Persisted {len(messages)} messages to conversation {conversation_id}")
483
+
484
+ except Exception as e:
485
+ logger.warning(f"Failed to persist messages: {e}")
308
486
 
309
487
  async def _handle_timeout(
310
488
  self,
@@ -4,12 +4,13 @@ ToolCallingAgent - A base class for agents that use tool calling.
4
4
  This eliminates the boilerplate of implementing the tool-calling loop
5
5
  in every agent. Just define your system prompt and tools, and the base
6
6
  class handles the rest.
7
+
8
+ Uses run_agentic_loop internally for the actual loop logic.
7
9
  """
8
10
 
9
- import json
10
11
  import logging
11
12
  from abc import abstractmethod
12
- from typing import Optional
13
+ from typing import Any, Optional
13
14
 
14
15
  from agent_runtime_core.interfaces import (
15
16
  AgentRuntime,
@@ -19,6 +20,7 @@ from agent_runtime_core.interfaces import (
19
20
  ToolRegistry,
20
21
  LLMClient,
21
22
  )
23
+ from agent_runtime_core.agentic_loop import run_agentic_loop
22
24
 
23
25
  logger = logging.getLogger(__name__)
24
26
 
@@ -26,231 +28,187 @@ logger = logging.getLogger(__name__)
26
28
  class ToolCallingAgent(AgentRuntime):
27
29
  """
28
30
  Base class for agents that use tool calling.
29
-
31
+
30
32
  Handles the standard tool-calling loop so you don't have to implement it
31
33
  in every agent. Just override the abstract properties and you're done.
32
-
34
+
35
+ Uses run_agentic_loop internally, with hooks for customization.
36
+
33
37
  Example:
34
38
  class MyAgent(ToolCallingAgent):
35
39
  @property
36
40
  def key(self) -> str:
37
41
  return "my-agent"
38
-
42
+
39
43
  @property
40
44
  def system_prompt(self) -> str:
41
45
  return "You are a helpful assistant..."
42
-
46
+
43
47
  @property
44
48
  def tools(self) -> ToolRegistry:
45
49
  return create_my_tools()
46
50
  """
47
-
51
+
48
52
  @property
49
53
  @abstractmethod
50
54
  def system_prompt(self) -> str:
51
55
  """
52
56
  System prompt for the agent.
53
-
57
+
54
58
  This is prepended to the conversation messages.
55
59
  """
56
60
  ...
57
-
61
+
58
62
  @property
59
63
  @abstractmethod
60
64
  def tools(self) -> ToolRegistry:
61
65
  """
62
66
  Tools available to the agent.
63
-
67
+
64
68
  Return a ToolRegistry with all tools registered.
65
69
  """
66
70
  ...
67
-
71
+
68
72
  @property
69
73
  def max_iterations(self) -> int:
70
74
  """
71
75
  Maximum number of tool-calling iterations.
72
-
76
+
73
77
  Override to change the default limit.
74
78
  """
75
- return 10
76
-
79
+ return 15
80
+
77
81
  @property
78
82
  def model(self) -> Optional[str]:
79
83
  """
80
84
  Model to use for this agent.
81
-
85
+
82
86
  If None, uses the default model from configuration.
83
87
  Override to use a specific model.
84
88
  """
85
89
  return None
86
-
90
+
87
91
  @property
88
92
  def temperature(self) -> Optional[float]:
89
93
  """
90
94
  Temperature for LLM generation.
91
-
95
+
92
96
  If None, uses the LLM client's default.
93
97
  Override to set a specific temperature.
94
98
  """
95
99
  return None
96
-
100
+
97
101
  def get_llm_client(self) -> LLMClient:
98
102
  """
99
103
  Get the LLM client to use.
100
-
104
+
101
105
  Override to customize LLM client selection.
102
106
  Default uses the configured client.
103
107
  """
104
108
  from agent_runtime_core.llm import get_llm_client
105
109
  return get_llm_client()
106
-
110
+
107
111
  async def before_run(self, ctx: RunContext) -> None:
108
112
  """
109
113
  Hook called before the agent run starts.
110
-
114
+
111
115
  Override to add custom initialization logic.
112
116
  """
113
117
  pass
114
-
118
+
115
119
  async def after_run(self, ctx: RunContext, result: RunResult) -> RunResult:
116
120
  """
117
121
  Hook called after the agent run completes.
118
-
122
+
119
123
  Override to add custom finalization logic.
120
124
  Can modify the result before returning.
121
125
  """
122
126
  return result
123
-
127
+
124
128
  async def on_tool_call(self, ctx: RunContext, tool_name: str, tool_args: dict) -> None:
125
129
  """
126
130
  Hook called before each tool execution.
127
-
131
+
128
132
  Override to add custom logic (logging, validation, etc.).
129
133
  """
130
134
  pass
131
-
132
- async def on_tool_result(self, ctx: RunContext, tool_name: str, result: any) -> any:
135
+
136
+ async def on_tool_result(self, ctx: RunContext, tool_name: str, result: Any) -> Any:
133
137
  """
134
138
  Hook called after each tool execution.
135
-
139
+
136
140
  Override to transform or validate tool results.
137
141
  Can return a modified result.
138
142
  """
139
143
  return result
140
-
144
+
141
145
  async def run(self, ctx: RunContext) -> RunResult:
142
146
  """
143
147
  Execute the agent with tool calling support.
144
-
145
- This implements the standard tool-calling loop:
146
- 1. Build messages with system prompt
147
- 2. Call LLM with tools
148
- 3. If tool calls, execute them and loop
149
- 4. If no tool calls, return final response
148
+
149
+ Uses run_agentic_loop internally with hooks for customization.
150
150
  """
151
- logger.info(f"[{self.key}] Starting run, input messages: {len(ctx.input_messages)}")
152
-
151
+ logger.debug(f"[{self.key}] Starting run, input messages: {len(ctx.input_messages)}")
152
+
153
153
  # Call before_run hook
154
154
  await self.before_run(ctx)
155
-
155
+
156
156
  # Get LLM client
157
157
  llm = self.get_llm_client()
158
-
158
+
159
159
  # Build messages with system prompt
160
+ # Use system_prompt_with_memory if available (from MemoryEnabledAgent mixin)
161
+ prompt = (
162
+ self.system_prompt_with_memory
163
+ if hasattr(self, 'system_prompt_with_memory')
164
+ else self.system_prompt
165
+ )
160
166
  messages = [
161
- {"role": "system", "content": self.system_prompt}
162
- ] + ctx.input_messages
163
-
164
- logger.info(f"[{self.key}] Built {len(messages)} messages (including system prompt)")
165
-
166
- # Run the agent loop (tool calling)
167
- iteration = 0
168
- final_response = None
169
-
170
- while iteration < self.max_iterations:
171
- iteration += 1
172
- logger.info(f"[{self.key}] Iteration {iteration}/{self.max_iterations}")
173
-
174
- # Generate response with tools
175
- logger.info(f"[{self.key}] Calling LLM...")
176
- response = await llm.generate(
177
- messages=messages,
178
- tools=self.tools.to_openai_format(),
179
- model=self.model,
180
- temperature=self.temperature,
181
- )
182
- logger.info(f"[{self.key}] LLM response received, tool_calls: {bool(response.message.get('tool_calls'))}")
183
-
184
- # Check if the model wants to call tools
185
- if response.message.get('tool_calls'):
186
- # Add the assistant message with tool calls
187
- messages.append(response.message)
188
-
189
- # Execute the tools
190
- tool_results = []
191
- for tool_call in response.message.get('tool_calls'):
192
- # Emit tool call event
193
- await ctx.emit(EventType.TOOL_CALL, {
194
- "tool_name": tool_call["function"]["name"],
195
- "tool_args": json.loads(tool_call["function"]["arguments"]),
196
- "tool_call_id": tool_call["id"],
197
- })
198
-
199
- # Call before_tool_call hook
200
- await self.on_tool_call(ctx, tool_call["function"]["name"], json.loads(tool_call["function"]["arguments"]))
201
-
202
- # Execute the tool
203
- result = await self.tools.execute(
204
- tool_call["function"]["name"],
205
- json.loads(tool_call["function"]["arguments"]),
206
- )
207
-
208
- # Call after_tool_result hook
209
- result = await self.on_tool_result(ctx, tool_call["function"]["name"], result)
210
-
211
- tool_results.append({
212
- "tool_call_id": tool_call["id"],
213
- "result": result,
214
- })
215
-
216
- # Emit tool result event
217
- await ctx.emit(EventType.TOOL_RESULT, {
218
- "tool_name": tool_call["function"]["name"],
219
- "tool_call_id": tool_call["id"],
220
- "result": result,
221
- })
222
-
223
- # Add tool results to messages
224
- for tr in tool_results:
225
- messages.append({
226
- "role": "tool",
227
- "tool_call_id": tr["tool_call_id"],
228
- "content": str(tr["result"]),
229
- })
230
- else:
231
- # No tool calls - we have the final response
232
- final_response = response.message["content"]
233
- logger.info(f"[{self.key}] Final response received: {final_response[:100] if final_response else 'None'}...")
234
- break
235
-
236
- # Emit the final assistant message
237
- if final_response:
238
- logger.info(f"[{self.key}] Emitting ASSISTANT_MESSAGE event")
239
- await ctx.emit(EventType.ASSISTANT_MESSAGE, {
240
- "content": final_response,
241
- })
242
- logger.info(f"[{self.key}] Event emitted successfully")
243
- else:
244
- logger.warning(f"[{self.key}] No final response to emit!")
245
-
246
- logger.info(f"[{self.key}] Returning RunResult")
167
+ {"role": "system", "content": prompt}
168
+ ] + list(ctx.input_messages)
169
+
170
+ # Create tool executor that calls our hooks
171
+ async def execute_tool(name: str, args: dict) -> Any:
172
+ # Call before hook
173
+ await self.on_tool_call(ctx, name, args)
174
+
175
+ # Execute the tool
176
+ result = await self.tools.execute(name, args)
177
+
178
+ # Call after hook (can transform result)
179
+ result = await self.on_tool_result(ctx, name, result)
180
+
181
+ return result
182
+
183
+ # Get tool schemas
184
+ tool_schemas = self.tools.to_openai_format() if self.tools.list_tools() else None
185
+
186
+ # Build LLM kwargs
187
+ llm_kwargs = {}
188
+ if self.temperature is not None:
189
+ llm_kwargs["temperature"] = self.temperature
190
+
191
+ # Run the agentic loop
192
+ # Note: agentic_loop emits ASSISTANT_MESSAGE for the final response
193
+ loop_result = await run_agentic_loop(
194
+ llm=llm,
195
+ messages=messages,
196
+ tools=tool_schemas,
197
+ execute_tool=execute_tool,
198
+ ctx=ctx,
199
+ model=self.model,
200
+ max_iterations=self.max_iterations,
201
+ emit_events=True,
202
+ **llm_kwargs,
203
+ )
204
+
247
205
  result = RunResult(
248
- final_output={"response": final_response},
249
- final_messages=messages,
250
- usage=response.usage if response else {},
206
+ final_output={"response": loop_result.final_content},
207
+ final_messages=loop_result.messages,
208
+ usage=loop_result.usage,
251
209
  )
252
-
210
+
253
211
  # Call after_run hook
254
212
  result = await self.after_run(ctx, result)
255
-
213
+
256
214
  return result