friday-framework-runtime 0.1.0a0__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,673 @@
1
+ """
2
+ Runtime Kernel — the headless operating system for Friday.
3
+
4
+ RuntimeKernel implements SystemRuntime and manages the full lifecycle
5
+ of framework services. It can be used without the CLI presentation
6
+ layer for headless execution, testing, and embedding.
7
+
8
+ Agent subsystem properties (agent_loader, agent_registry, orchestrator)
9
+ are lazy-initialized on first access, preserving the existing error
10
+ semantics from FridayContainer.
11
+
12
+ Ref: Phase 1 Build Plan, Step 4.3
13
+ """
14
+
15
+ import logging
16
+ import uuid
17
+ from collections.abc import Callable
18
+ from pathlib import Path
19
+ from typing import Any, cast
20
+
21
+ from friday_agent import (
22
+ AgentLoader,
23
+ AgentOrchestrator,
24
+ AgentProfile,
25
+ AgentRegistry,
26
+ AutonomousAgent,
27
+ CopilotAgent,
28
+ DefaultToolRegistry,
29
+ InteractiveAgent,
30
+ )
31
+ from friday_core.interfaces import ToolRegistry
32
+ from friday_core.interfaces.runtime import SystemRuntime
33
+ from friday_core.profiles import MemoryProfile
34
+ from friday_core.security import SanitizationConfig
35
+
36
+ from friday_runtime.config import RuntimeConfig
37
+ from friday_runtime.di import DIContainer
38
+ from friday_runtime.tool_loading import (
39
+ collect_profile_tool_references,
40
+ validate_agent_tool_bindings,
41
+ )
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ class RuntimeKernel(SystemRuntime):
47
+ """
48
+ Headless runtime kernel for the Friday Agent Framework.
49
+
50
+ Manages service lifecycle (LLM, memory, tools, telemetry) and
51
+ provides agent spawning and workflow execution. Does not depend
52
+ on any CLI or presentation layer.
53
+
54
+ Usage:
55
+ kernel = RuntimeKernel(
56
+ config=RuntimeConfig.from_env(),
57
+ memory_profile=MemoryProfile.FULL,
58
+ )
59
+ await kernel.start()
60
+
61
+ agent = kernel.spawn_agent("researcher")
62
+ result = await agent.run("Find data about Python parsers")
63
+
64
+ await kernel.stop()
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ config: RuntimeConfig | None = None,
70
+ memory_profile: MemoryProfile = MemoryProfile.FULL,
71
+ sanitization_config: SanitizationConfig | None = None,
72
+ ) -> None:
73
+ """
74
+ Initialize the runtime kernel.
75
+
76
+ Args:
77
+ config: Runtime configuration (defaults to env-based).
78
+ memory_profile: Memory storage profile.
79
+ sanitization_config: Optional output sanitization config,
80
+ forwarded to CopilotAgent on creation.
81
+ """
82
+ self._config = config or RuntimeConfig.from_env()
83
+ self._memory_profile = memory_profile
84
+ self._sanitization_config = sanitization_config
85
+
86
+ self._di = DIContainer(self._config, self._memory_profile)
87
+ self._initialized = False
88
+
89
+ # Lazy agent subsystem
90
+ self._agent_loader: AgentLoader | None = None
91
+ self._agent_registry: AgentRegistry | None = None
92
+ self._orchestrator: AgentOrchestrator | None = None
93
+ self._agent_factories: dict[str, Callable[[AgentProfile], Any]] = {
94
+ "interactive": self._create_interactive_agent,
95
+ "autonomous": self._create_autonomous_agent,
96
+ }
97
+
98
+ # --- SystemRuntime Implementation ---
99
+
100
+ async def start(self) -> None:
101
+ """
102
+ Initialize all eager services (LLM, memory, tools, telemetry).
103
+
104
+ Must be called before any other operations.
105
+ """
106
+ if self._initialized:
107
+ return
108
+
109
+ logger.info("Starting RuntimeKernel...")
110
+ await self._di.boot()
111
+ try:
112
+ self._agent_registry = self._build_agent_registry()
113
+ except Exception:
114
+ await self._di.shutdown()
115
+ raise
116
+ self._initialized = True
117
+ logger.info("RuntimeKernel started successfully")
118
+
119
+ async def stop(self) -> None:
120
+ """
121
+ Gracefully shut down all services.
122
+
123
+ Flushes logs, persists memory state, and releases resources.
124
+ Safe to call multiple times.
125
+ """
126
+ logger.info("Stopping RuntimeKernel...")
127
+ await self._di.shutdown()
128
+ self._initialized = False
129
+ logger.info("RuntimeKernel stopped")
130
+
131
+ async def create_session(self, user_id: str = "default") -> str:
132
+ """
133
+ Create a new session identifier.
134
+
135
+ Phase 1 stub: returns a UUID string without persistent
136
+ session tracking.
137
+
138
+ Args:
139
+ user_id: User identifier for the session.
140
+
141
+ Returns:
142
+ A unique session ID string.
143
+ """
144
+ session_id = str(uuid.uuid4())
145
+ logger.debug(f"Created session {session_id} for user {user_id}")
146
+ return session_id
147
+
148
+ def spawn_agent(self, name: str) -> Any:
149
+ """
150
+ Create an agent instance by name from the registry.
151
+
152
+ Args:
153
+ name: Registered agent name.
154
+
155
+ Returns:
156
+ Configured agent instance.
157
+
158
+ Raises:
159
+ KeyError: If no agent profile exists with the given name.
160
+ RuntimeError: If kernel not started or no factory configured.
161
+ """
162
+ self._ensure_initialized()
163
+ return self.agent_registry.create_agent(name)
164
+
165
+ async def execute_workflow(
166
+ self,
167
+ workflow_name: str,
168
+ initial_inputs: dict[str, Any] | None = None,
169
+ ) -> Any:
170
+ """
171
+ Load and execute a named workflow definition.
172
+
173
+ Args:
174
+ workflow_name: Name of the workflow to execute.
175
+ initial_inputs: Optional initial variable bindings.
176
+
177
+ Returns:
178
+ WorkflowExecution with final state.
179
+ """
180
+ self._ensure_initialized()
181
+ from friday_agent.workflows import WorkflowEngine, WorkflowLoader
182
+ from friday_memory import ScratchPadStore
183
+
184
+ workflow_session_id = await self.create_session(user_id="workflow")
185
+ await self.orchestrator.set_session(workflow_session_id, user_id="workflow")
186
+
187
+ loader = WorkflowLoader(self._resolve_workflows_dir())
188
+ scratchpad = ScratchPadStore()
189
+ engine = WorkflowEngine(
190
+ orchestrator=self.orchestrator,
191
+ scratchpad=scratchpad,
192
+ telemetry=self.telemetry,
193
+ agent_info_resolver=self.get_agent_info,
194
+ )
195
+
196
+ if self._config.workflow_mode == "legacy":
197
+ bundle = loader.load_legacy_as_canonical(workflow_name)
198
+ self._emit_workflow_routing_event(
199
+ workflow_name=workflow_name,
200
+ requested_mode="legacy",
201
+ resolved_mode="legacy_adapter",
202
+ )
203
+ return await engine.execute_canonical(
204
+ bundle.workflow,
205
+ bundle.pipelines,
206
+ initial_inputs,
207
+ role_bindings=self._config.agents_role_bindings,
208
+ execution_id=workflow_session_id,
209
+ )
210
+
211
+ if self._config.workflow_mode == "canonical":
212
+ bundle = loader.load_canonical(workflow_name)
213
+ self._emit_workflow_routing_event(
214
+ workflow_name=workflow_name,
215
+ requested_mode="canonical",
216
+ resolved_mode="canonical",
217
+ )
218
+ return await engine.execute_canonical(
219
+ bundle.workflow,
220
+ bundle.pipelines,
221
+ initial_inputs,
222
+ role_bindings=self._config.agents_role_bindings,
223
+ execution_id=workflow_session_id,
224
+ )
225
+
226
+ # auto mode: attempt canonical first, then fall back to legacy path.
227
+ try:
228
+ bundle = loader.load_canonical(workflow_name)
229
+ except Exception:
230
+ bundle = loader.load_legacy_as_canonical(workflow_name)
231
+ logger.warning(
232
+ "Canonical workflow load failed for '%s'; using legacy adapter path",
233
+ workflow_name,
234
+ )
235
+ self._emit_workflow_routing_event(
236
+ workflow_name=workflow_name,
237
+ requested_mode="auto",
238
+ resolved_mode="legacy_adapter",
239
+ )
240
+ return await engine.execute_canonical(
241
+ bundle.workflow,
242
+ bundle.pipelines,
243
+ initial_inputs,
244
+ role_bindings=self._config.agents_role_bindings,
245
+ execution_id=workflow_session_id,
246
+ )
247
+
248
+ self._emit_workflow_routing_event(
249
+ workflow_name=workflow_name,
250
+ requested_mode="auto",
251
+ resolved_mode="canonical",
252
+ )
253
+ return await engine.execute_canonical(
254
+ bundle.workflow,
255
+ bundle.pipelines,
256
+ initial_inputs,
257
+ role_bindings=self._config.agents_role_bindings,
258
+ execution_id=workflow_session_id,
259
+ )
260
+
261
+ # --- Eager Service Accessors ---
262
+
263
+ @property
264
+ def config(self) -> RuntimeConfig:
265
+ """Get runtime configuration."""
266
+ return self._config
267
+
268
+ @property
269
+ def memory_profile(self) -> MemoryProfile:
270
+ """Get current memory profile."""
271
+ return self._memory_profile
272
+
273
+ @property
274
+ def telemetry(self) -> Any:
275
+ """Get telemetry interface (may be None if disabled)."""
276
+ return self._di.telemetry
277
+
278
+ @property
279
+ def llm(self) -> Any:
280
+ """Get LLM service."""
281
+ self._ensure_initialized()
282
+ return self._di.llm
283
+
284
+ @property
285
+ def embeddings(self) -> Any:
286
+ """Get embedding service."""
287
+ self._ensure_initialized()
288
+ return self._di.embeddings
289
+
290
+ @property
291
+ def memory(self) -> Any:
292
+ """Get memory controller."""
293
+ self._ensure_initialized()
294
+ return self._di.memory
295
+
296
+ @property
297
+ def context_engine(self) -> Any:
298
+ """Get context assembler."""
299
+ self._ensure_initialized()
300
+ return self._di.context_engine
301
+
302
+ @property
303
+ def multi_agent_memory(self) -> Any:
304
+ """Get multi-agent memory facade when multi-agent mode is enabled."""
305
+ self._ensure_initialized()
306
+ return self._di.multi_agent_memory
307
+
308
+ @property
309
+ def tool_registry(self) -> DefaultToolRegistry:
310
+ """Get tool registry."""
311
+ self._ensure_initialized()
312
+ assert self._di.tool_registry is not None
313
+ return self._di.tool_registry
314
+
315
+ # --- Lazy Agent Subsystem ---
316
+
317
+ @property
318
+ def agent_loader(self) -> AgentLoader:
319
+ """
320
+ Get or create the agent loader (lazy).
321
+
322
+ Created on first access. Raises if agents_dir is missing.
323
+ """
324
+ if self._agent_loader is None:
325
+ agents_dir = (
326
+ Path(self._config.agents_dir)
327
+ if self._config.agents_dir
328
+ else Path("agents")
329
+ )
330
+ self._agent_loader = AgentLoader(agents_dir)
331
+ return self._agent_loader
332
+
333
+ @property
334
+ def agent_registry(self) -> AgentRegistry:
335
+ """
336
+ Get or create the agent registry (lazy).
337
+
338
+ Auto-configured with:
339
+ - AgentLoader for YAML loading
340
+ - Factory for agent instantiation
341
+ - All agents from the agents directory (if it exists)
342
+ """
343
+ if self._agent_registry is None:
344
+ self._agent_registry = self._build_agent_registry()
345
+
346
+ return self._agent_registry
347
+
348
+ @property
349
+ def orchestrator(self) -> AgentOrchestrator:
350
+ """
351
+ Get or create the agent orchestrator (lazy).
352
+
353
+ Configured with the agent registry and first available agent
354
+ as the default.
355
+ """
356
+ if self._orchestrator is None:
357
+ default_agent = None
358
+ available_agents = self.agent_registry.list_agents()
359
+ if available_agents:
360
+ default_agent = available_agents[0]
361
+ logger.debug(f"Using '{default_agent}' as default orchestrator agent")
362
+
363
+ self._orchestrator = AgentOrchestrator(
364
+ registry=self.agent_registry,
365
+ default_agent=default_agent,
366
+ )
367
+ logger.info("Agent orchestrator initialized")
368
+
369
+ return self._orchestrator
370
+
371
+ # --- Agent Factory ---
372
+
373
+ def register_agent_factory(
374
+ self,
375
+ agent_type: str,
376
+ factory: Callable[[AgentProfile], Any],
377
+ ) -> None:
378
+ """Register or override a runtime factory for a profile `agent_type`."""
379
+ self._agent_factories[agent_type] = factory
380
+
381
+ def get_copilot_agent(
382
+ self,
383
+ profile: AgentProfile | None = None,
384
+ ) -> CopilotAgent:
385
+ """
386
+ Create a CopilotAgent with all dependencies wired.
387
+
388
+ Args:
389
+ profile: Agent profile (uses default if not provided).
390
+
391
+ Returns:
392
+ Configured CopilotAgent instance.
393
+ """
394
+ self._ensure_initialized()
395
+
396
+ if profile is None:
397
+ profile = self._default_profile()
398
+
399
+ return CopilotAgent(
400
+ profile=profile,
401
+ memory=self.memory,
402
+ context_engine=self.context_engine,
403
+ llm=self._get_llm_for_profile(profile),
404
+ tool_registry=self._tool_registry_interface(),
405
+ telemetry=self.telemetry,
406
+ sanitization_config=self._sanitization_config,
407
+ )
408
+
409
+ def get_interactive_agent(
410
+ self,
411
+ profile: AgentProfile | None = None,
412
+ ) -> InteractiveAgent:
413
+ """
414
+ Create an InteractiveAgent with all dependencies wired.
415
+
416
+ Args:
417
+ profile: Agent profile (uses default if not provided).
418
+
419
+ Returns:
420
+ Configured InteractiveAgent instance.
421
+ """
422
+ self._ensure_initialized()
423
+
424
+ if profile is None:
425
+ profile = self._default_profile()
426
+
427
+ return InteractiveAgent(
428
+ profile=profile,
429
+ memory=self.memory,
430
+ context_engine=self.context_engine,
431
+ llm=self._get_llm_for_profile(profile),
432
+ tool_registry=self._tool_registry_interface(),
433
+ telemetry=self.telemetry,
434
+ sanitization_config=self._sanitization_config,
435
+ )
436
+
437
+ def get_team_agent(self, name: str) -> Any:
438
+ """
439
+ Get or create a named agent from the registry.
440
+
441
+ Args:
442
+ name: Agent name from registry.
443
+
444
+ Returns:
445
+ Configured agent instance.
446
+ """
447
+ self._ensure_initialized()
448
+ return self.agent_registry.create_agent(name)
449
+
450
+ def list_available_agents(self) -> list[str]:
451
+ """
452
+ List available agent names from the registry.
453
+
454
+ Returns:
455
+ Sorted list of agent names.
456
+ """
457
+ return cast(list[str], self.agent_registry.list_agents())
458
+
459
+ def get_agent_info(self, name: str) -> dict | None:
460
+ """
461
+ Get information about an agent.
462
+
463
+ Args:
464
+ name: Agent name.
465
+
466
+ Returns:
467
+ Dict with agent info or None if not found.
468
+ """
469
+ profile = self.agent_registry.get(name)
470
+ if profile is None:
471
+ return None
472
+
473
+ return {
474
+ "name": profile.name,
475
+ "role": profile.role,
476
+ "goal": profile.goal,
477
+ "llm_service": self._resolve_llm_service_name(profile),
478
+ "agent_type": profile.agent_type,
479
+ "capabilities": profile.capabilities,
480
+ "tools": profile.tools,
481
+ "tool_autonomy": profile.tool_autonomy,
482
+ }
483
+
484
+ # --- Private Helpers ---
485
+
486
+ def _ensure_initialized(self) -> None:
487
+ """Raise if kernel not started."""
488
+ if not self._initialized:
489
+ raise RuntimeError("RuntimeKernel not started. Call start() first.")
490
+
491
+ def _build_agent_registry(self) -> AgentRegistry:
492
+ """Create and validate the registry against runtime LLM bindings."""
493
+ registry = AgentRegistry(self.agent_loader)
494
+ registry.set_factory(self._create_agent)
495
+
496
+ if self.agent_loader.agents_dir.exists():
497
+ registry.load_all_from_directory()
498
+ self._validate_agent_profiles(registry.list_profiles())
499
+
500
+ return registry
501
+
502
+ def _resolve_workflows_dir(self) -> Path:
503
+ """Resolve workflow bundle root for runtime workflow execution."""
504
+ configured = self._config.workflows_dir
505
+ if configured:
506
+ raw = Path(configured).expanduser()
507
+ if raw.is_absolute():
508
+ return raw.resolve()
509
+ install_root = Path(self._config.install_root or ".").expanduser().resolve()
510
+ return (install_root / raw).resolve()
511
+
512
+ agents_dir = Path(self._config.agents_dir or "agents")
513
+ return (agents_dir / "workflows").resolve()
514
+
515
+ def _validate_agent_profiles(self, profiles: list[AgentProfile]) -> None:
516
+ """Fail startup deterministically on invalid agent/runtime bindings."""
517
+ registry = self._di.tool_registry
518
+ if registry is None:
519
+ raise RuntimeError("Runtime tool registry not initialized during startup")
520
+
521
+ available_tool_names = set(cast(list[str], registry.list_names()))
522
+ for profile in profiles:
523
+ resolved_service = self._resolve_llm_service_name(profile)
524
+ self._resolve_agent_factory(profile.agent_type)
525
+ validate_agent_tool_bindings(
526
+ agent_name=profile.name,
527
+ declared_tools=collect_profile_tool_references(profile),
528
+ available_tool_names=available_tool_names,
529
+ )
530
+ logger.debug(
531
+ "Validated agent '%s' LLM binding to service '%s' (agent_type=%s)",
532
+ profile.name,
533
+ resolved_service,
534
+ profile.agent_type,
535
+ )
536
+
537
+ def _resolve_llm_service_name(self, profile: AgentProfile) -> str:
538
+ """Resolve the effective runtime LLM service for a profile."""
539
+ if profile.llm_service is not None:
540
+ if profile.llm_service not in self._config.llm_pool.services:
541
+ raise ValueError(
542
+ f"Agent '{profile.name}' references unknown llm_service "
543
+ f"'{profile.llm_service}'"
544
+ )
545
+ return cast(str, profile.llm_service)
546
+
547
+ if self._config.strict_agent_llm_binding:
548
+ raise ValueError(
549
+ f"Agent '{profile.name}' must declare llm_service when "
550
+ "strict_agent_llm_binding is enabled"
551
+ )
552
+
553
+ return cast(str, self._config.llm_pool.default_service)
554
+
555
+ def _get_llm_for_profile(self, profile: AgentProfile) -> Any:
556
+ """Return the configured runtime LLM client for the agent profile."""
557
+ service_name = self._resolve_llm_service_name(profile)
558
+ try:
559
+ return self._di.llm_pool[service_name]
560
+ except KeyError as exc:
561
+ raise RuntimeError(
562
+ f"Runtime LLM pool missing configured service '{service_name}'"
563
+ ) from exc
564
+
565
+ def _get_llm_service_config_for_profile(self, profile: AgentProfile) -> Any:
566
+ """Return the resolved runtime LLM service config for the profile."""
567
+ return self._config.get_llm_service_config(self._resolve_llm_service_name(profile))
568
+
569
+ def _resolve_agent_factory(self, agent_type: str) -> Callable[[AgentProfile], Any]:
570
+ """Resolve the runtime factory for the profile `agent_type`."""
571
+ try:
572
+ return self._agent_factories[agent_type]
573
+ except KeyError as exc:
574
+ raise ValueError(
575
+ f"Unregistered agent_type '{agent_type}' for runtime factory mapping"
576
+ ) from exc
577
+
578
+ def _create_agent(self, profile: AgentProfile) -> Any:
579
+ """
580
+ Factory function for creating agents from profiles.
581
+
582
+ Used by AgentRegistry when create_agent() is called.
583
+
584
+ Args:
585
+ profile: Agent profile to instantiate.
586
+
587
+ Returns:
588
+ Configured agent instance.
589
+ """
590
+ return self._resolve_agent_factory(profile.agent_type)(profile)
591
+
592
+ def _tool_registry_interface(self) -> ToolRegistry:
593
+ """Return the registry narrowed to the protocol expected by agents."""
594
+ return cast(ToolRegistry, self.tool_registry)
595
+
596
+ def _create_interactive_agent(self, profile: AgentProfile) -> InteractiveAgent:
597
+ """Create the default interactive runtime agent."""
598
+ llm_service_config = self._get_llm_service_config_for_profile(profile)
599
+ return InteractiveAgent(
600
+ profile=profile,
601
+ memory=self.memory,
602
+ context_engine=self.context_engine,
603
+ llm=self._get_llm_for_profile(profile),
604
+ tool_registry=self._tool_registry_interface(),
605
+ telemetry=self.telemetry,
606
+ sanitization_config=self._sanitization_config,
607
+ runtime_base_system_prompt=self._config.prompting.base_system_prompt,
608
+ resolved_llm_service_name=self._resolve_llm_service_name(profile),
609
+ resolved_provider_name=llm_service_config.provider,
610
+ )
611
+
612
+ def _create_autonomous_agent(self, profile: AgentProfile) -> AutonomousAgent:
613
+ """Create the autonomous runtime agent."""
614
+ llm_service_config = self._get_llm_service_config_for_profile(profile)
615
+ return AutonomousAgent(
616
+ profile=profile,
617
+ memory=self.memory,
618
+ context_engine=self.context_engine,
619
+ llm=self._get_llm_for_profile(profile),
620
+ tool_registry=self._tool_registry_interface(),
621
+ telemetry=self.telemetry,
622
+ sanitization_config=self._sanitization_config,
623
+ runtime_base_system_prompt=self._config.prompting.base_system_prompt,
624
+ resolved_llm_service_name=self._resolve_llm_service_name(profile),
625
+ resolved_provider_name=llm_service_config.provider,
626
+ )
627
+
628
+ def _default_profile(self) -> AgentProfile:
629
+ """Create default agent profile."""
630
+ return AgentProfile(
631
+ name="Friday",
632
+ role="Personal Assistant",
633
+ goal="Help users accomplish their tasks efficiently and accurately",
634
+ system_prompt_template=(
635
+ "You are {name}, a {role}.\n\n"
636
+ "Your goal: {goal}\n\n"
637
+ "You have access to a persistent memory system. "
638
+ "Use the available tools to:\n"
639
+ "- Remember important information with store_fact\n"
640
+ "- Recall relevant context with recall_memory\n"
641
+ "- Save working notes with scratchpad_write\n\n"
642
+ "Be helpful, accurate, and concise in your responses."
643
+ ),
644
+ tools=[
645
+ "recall_memory",
646
+ "store_fact",
647
+ "scratchpad_write",
648
+ "scratchpad_read",
649
+ "scratchpad_list",
650
+ ],
651
+ temperature=0.7,
652
+ llm_service=self._config.llm_pool.default_service,
653
+ agent_type="interactive",
654
+ )
655
+
656
+ def _emit_workflow_routing_event(
657
+ self,
658
+ *,
659
+ workflow_name: str,
660
+ requested_mode: str,
661
+ resolved_mode: str,
662
+ ) -> None:
663
+ """Emit auditable workflow routing information when telemetry is enabled."""
664
+ if self.telemetry is None:
665
+ return
666
+ self.telemetry.capture_event(
667
+ "workflow_mode_selected",
668
+ {
669
+ "workflow_name": workflow_name,
670
+ "requested_mode": requested_mode,
671
+ "resolved_mode": resolved_mode,
672
+ },
673
+ )