refactor: simplify agent loading
This commit is contained in:
+15
-63
@@ -1,71 +1,23 @@
|
||||
"""
|
||||
Aden Hive Framework: A goal-driven agent runtime optimized for Builder observability.
|
||||
"""Hive Agent Framework.
|
||||
|
||||
The runtime is designed around DECISIONS, not just actions. Every significant
|
||||
choice the agent makes is captured with:
|
||||
- What it was trying to do (intent)
|
||||
- What options it considered
|
||||
- What it chose and why
|
||||
- What happened as a result
|
||||
- Whether that was good or bad (evaluated post-hoc)
|
||||
|
||||
This gives the Builder LLM the information it needs to improve agent behavior.
|
||||
|
||||
## Testing Framework
|
||||
|
||||
The framework includes a Goal-Based Testing system (Goal → Agent → Eval):
|
||||
- Generate tests from Goal success_criteria and constraints
|
||||
- Mandatory user approval before tests are stored
|
||||
- Parallel test execution with error categorization
|
||||
- Debug tools with fix suggestions
|
||||
|
||||
See `framework.testing` for details.
|
||||
Core classes:
|
||||
AgentHost -- hosts agents, manages entry points and pipeline
|
||||
Orchestrator -- routes between nodes in a graph
|
||||
AgentLoop -- the LLM + tool execution loop (one per node)
|
||||
AgentLoader -- loads agent.json from disk, builds pipeline
|
||||
DecisionTracker -- records decisions for post-hoc analysis
|
||||
"""
|
||||
|
||||
from framework.llm import LLMProvider
|
||||
|
||||
try:
|
||||
from framework.llm import AnthropicProvider # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
from framework.agent_loop import AgentLoop
|
||||
from framework.host import AgentHost
|
||||
from framework.loader import AgentLoader
|
||||
from framework.tracker.decision_tracker import DecisionTracker # noqa: F401
|
||||
from framework.schemas.decision import Decision, DecisionEvaluation, Option, Outcome
|
||||
from framework.schemas.run import Problem, Run, RunSummary
|
||||
|
||||
# Testing framework
|
||||
from framework.testing import (
|
||||
ApprovalStatus,
|
||||
DebugTool,
|
||||
ErrorCategory,
|
||||
Test,
|
||||
TestResult,
|
||||
TestStorage,
|
||||
TestSuiteResult,
|
||||
)
|
||||
from framework.orchestrator import Orchestrator
|
||||
from framework.tracker import DecisionTracker
|
||||
|
||||
__all__ = [
|
||||
# Schemas
|
||||
"Decision",
|
||||
"Option",
|
||||
"Outcome",
|
||||
"DecisionEvaluation",
|
||||
"Run",
|
||||
"RunSummary",
|
||||
"Problem",
|
||||
# Runtime
|
||||
"Runtime",
|
||||
# LLM
|
||||
"LLMProvider",
|
||||
"AnthropicProvider",
|
||||
# Runner
|
||||
"AgentHost",
|
||||
"AgentLoader",
|
||||
# Testing
|
||||
"Test",
|
||||
"TestResult",
|
||||
"TestSuiteResult",
|
||||
"TestStorage",
|
||||
"ApprovalStatus",
|
||||
"ErrorCategory",
|
||||
"DebugTool",
|
||||
"AgentLoop",
|
||||
"DecisionTracker",
|
||||
"Orchestrator",
|
||||
]
|
||||
|
||||
@@ -204,66 +204,6 @@ def build_escalate_tool() -> Tool:
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def build_report_to_parent_tool() -> Tool:
|
||||
"""Build the synthetic report_to_parent tool for sub-agent progress reports.
|
||||
|
||||
Sub-agents call this to send one-way progress updates, partial findings,
|
||||
or status reports to the parent node (and external observers via event bus)
|
||||
without blocking execution.
|
||||
|
||||
When ``wait_for_response`` is True, the sub-agent blocks until the parent
|
||||
relays the user's response — used for escalation (e.g. login pages, CAPTCHAs).
|
||||
|
||||
When ``mark_complete`` is True, the sub-agent terminates immediately after
|
||||
sending the report — no need to call set_output for each output key.
|
||||
"""
|
||||
return Tool(
|
||||
name="report_to_parent",
|
||||
description=(
|
||||
"Send a report to the parent agent. By default this is fire-and-forget: "
|
||||
"the parent receives the report but does not respond. "
|
||||
"Set wait_for_response=true to BLOCK until the user replies — use this "
|
||||
"when you need human intervention (e.g. login pages, CAPTCHAs, "
|
||||
"authentication walls). The user's response is returned as the tool result. "
|
||||
"Set mark_complete=true to finish your task and terminate immediately "
|
||||
"after sending the report — use this when your findings are in the "
|
||||
"message/data fields and you don't need to call set_output."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "A human-readable status or progress message.",
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Optional structured data to include with the report.",
|
||||
},
|
||||
"wait_for_response": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"If true, block execution until the user responds. "
|
||||
"Use for escalation scenarios requiring human intervention."
|
||||
),
|
||||
"default": False,
|
||||
},
|
||||
"mark_complete": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"If true, terminate the sub-agent immediately after sending "
|
||||
"this report. The report message and data are delivered to the "
|
||||
"parent as the final result. No set_output calls are needed."
|
||||
),
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": ["message"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def handle_set_output(
|
||||
tool_input: dict[str, Any],
|
||||
output_keys: list[str] | None,
|
||||
|
||||
@@ -28,7 +28,7 @@ from framework.orchestrator.orchestrator import ExecutionResult
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.loader.mcp_registry import MCPRegistry
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
from framework.host.agent_host import AgentHost, create_agent_runtime
|
||||
from framework.host.agent_host import AgentHost
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
|
||||
from .config import default_config
|
||||
@@ -613,7 +613,7 @@ class CredentialTesterAgent:
|
||||
|
||||
graph = self._build_graph()
|
||||
|
||||
self._agent_runtime = create_agent_runtime(
|
||||
self._agent_runtime = AgentHost(
|
||||
graph=graph,
|
||||
goal=goal,
|
||||
storage_path=self._storage_path,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from framework.host.agent_host import ( # noqa: F401
|
||||
AgentHost,
|
||||
AgentRuntimeConfig,
|
||||
create_agent_runtime,
|
||||
)
|
||||
from framework.host.event_bus import AgentEvent, EventBus, EventType # noqa: F401
|
||||
from framework.host.execution_manager import ( # noqa: F401
|
||||
|
||||
+339
-445
@@ -483,328 +483,7 @@ class AgentHost:
|
||||
self._event_subscriptions.append(sub_id)
|
||||
|
||||
# Start timer-driven entry points
|
||||
for ep_id, spec in self._entry_points.items():
|
||||
if spec.trigger_type != "timer":
|
||||
continue
|
||||
|
||||
tc = spec.trigger_config
|
||||
cron_expr = tc.get("cron")
|
||||
_raw_interval = tc.get("interval_minutes")
|
||||
interval = float(_raw_interval) if _raw_interval is not None else None
|
||||
run_immediately = tc.get("run_immediately", False)
|
||||
|
||||
if cron_expr:
|
||||
# Cron expression mode — takes priority over interval_minutes
|
||||
try:
|
||||
from croniter import croniter
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"croniter is required for cron-based entry points. "
|
||||
"Install it with: uv pip install croniter"
|
||||
) from e
|
||||
|
||||
try:
|
||||
if not croniter.is_valid(cron_expr):
|
||||
raise ValueError(f"Invalid cron expression: {cron_expr}")
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
"Entry point '%s' has invalid cron config: %s",
|
||||
ep_id,
|
||||
e,
|
||||
)
|
||||
continue
|
||||
|
||||
def _make_cron_timer(
|
||||
entry_point_id: str,
|
||||
expr: str,
|
||||
immediate: bool,
|
||||
idle_timeout: float = 300,
|
||||
):
|
||||
async def _cron_loop():
|
||||
from croniter import croniter
|
||||
|
||||
_persistent_session_id: str | None = None
|
||||
if not immediate:
|
||||
cron = croniter(expr, datetime.now())
|
||||
next_dt = cron.get_next(datetime)
|
||||
sleep_secs = (next_dt - datetime.now()).total_seconds()
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
time.monotonic() + sleep_secs
|
||||
)
|
||||
await asyncio.sleep(max(0, sleep_secs))
|
||||
while self._running:
|
||||
# Calculate next fire time upfront (used by skip paths too)
|
||||
cron = croniter(expr, datetime.now())
|
||||
next_dt = cron.get_next(datetime)
|
||||
sleep_secs = (next_dt - datetime.now()).total_seconds()
|
||||
|
||||
# Gate: skip tick if timers are explicitly paused
|
||||
if self._timers_paused:
|
||||
logger.debug(
|
||||
"Cron '%s': paused, skipping tick",
|
||||
entry_point_id,
|
||||
)
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
time.monotonic() + sleep_secs
|
||||
)
|
||||
await asyncio.sleep(max(0, sleep_secs))
|
||||
continue
|
||||
|
||||
# Gate: skip tick if ANY stream is actively working.
|
||||
# If the execution is idle (no LLM/tool activity
|
||||
# beyond idle_timeout) let the timer proceed —
|
||||
# execute() will cancel the stale execution.
|
||||
_any_active = False
|
||||
_min_idle = float("inf")
|
||||
for _s in self._streams.values():
|
||||
if _s.active_execution_ids:
|
||||
_any_active = True
|
||||
_idle = _s.agent_idle_seconds
|
||||
if _idle < _min_idle:
|
||||
_min_idle = _idle
|
||||
logger.info(
|
||||
"Cron '%s': gate — active=%s, idle=%.1fs, timeout=%ds",
|
||||
entry_point_id,
|
||||
_any_active,
|
||||
_min_idle,
|
||||
idle_timeout,
|
||||
)
|
||||
if _any_active and _min_idle < idle_timeout:
|
||||
logger.info(
|
||||
"Cron '%s': agent actively working, skipping tick",
|
||||
entry_point_id,
|
||||
)
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
time.monotonic() + sleep_secs
|
||||
)
|
||||
await asyncio.sleep(max(0, sleep_secs))
|
||||
continue
|
||||
|
||||
self._timer_next_fire.pop(entry_point_id, None)
|
||||
try:
|
||||
ep_spec = self._entry_points.get(entry_point_id)
|
||||
is_isolated = ep_spec and ep_spec.isolation_level == "isolated"
|
||||
if is_isolated:
|
||||
if _persistent_session_id:
|
||||
session_state = {
|
||||
"resume_session_id": _persistent_session_id
|
||||
}
|
||||
else:
|
||||
session_state = None
|
||||
else:
|
||||
session_state = self._get_primary_session_state(
|
||||
exclude_entry_point=entry_point_id
|
||||
)
|
||||
# Gate: skip tick if no active session
|
||||
if session_state is None:
|
||||
logger.debug(
|
||||
"Cron '%s': no active session, skipping",
|
||||
entry_point_id,
|
||||
)
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
time.monotonic() + sleep_secs
|
||||
)
|
||||
await asyncio.sleep(max(0, sleep_secs))
|
||||
continue
|
||||
|
||||
exec_id = await self.trigger(
|
||||
entry_point_id,
|
||||
{
|
||||
"event": {
|
||||
"source": "timer",
|
||||
"reason": "scheduled",
|
||||
}
|
||||
},
|
||||
session_state=session_state,
|
||||
)
|
||||
if not _persistent_session_id and is_isolated:
|
||||
_persistent_session_id = exec_id
|
||||
logger.info(
|
||||
"Cron fired for entry point '%s' (expr: %s)",
|
||||
entry_point_id,
|
||||
expr,
|
||||
)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Cron trigger failed for '%s'",
|
||||
entry_point_id,
|
||||
exc_info=True,
|
||||
)
|
||||
# Calculate next fire from now
|
||||
cron = croniter(expr, datetime.now())
|
||||
next_dt = cron.get_next(datetime)
|
||||
sleep_secs = (next_dt - datetime.now()).total_seconds()
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
time.monotonic() + sleep_secs
|
||||
)
|
||||
await asyncio.sleep(max(0, sleep_secs))
|
||||
|
||||
return _cron_loop
|
||||
|
||||
task = asyncio.create_task(
|
||||
_make_cron_timer(
|
||||
ep_id,
|
||||
cron_expr,
|
||||
run_immediately,
|
||||
idle_timeout=float(tc.get("idle_timeout_seconds", 300)),
|
||||
)()
|
||||
)
|
||||
self._timer_tasks.append(task)
|
||||
logger.info(
|
||||
"Started cron timer for entry point '%s' with expression '%s'%s",
|
||||
ep_id,
|
||||
cron_expr,
|
||||
" (immediate first run)" if run_immediately else "",
|
||||
)
|
||||
|
||||
elif interval and interval > 0:
|
||||
# Fixed interval mode (original behavior)
|
||||
def _make_timer(
|
||||
entry_point_id: str,
|
||||
mins: float,
|
||||
immediate: bool,
|
||||
idle_timeout: float = 300,
|
||||
):
|
||||
async def _timer_loop():
|
||||
interval_secs = mins * 60
|
||||
_persistent_session_id: str | None = None
|
||||
if not immediate:
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
time.monotonic() + interval_secs
|
||||
)
|
||||
await asyncio.sleep(interval_secs)
|
||||
while self._running:
|
||||
# Gate: skip tick if timers are explicitly paused
|
||||
if self._timers_paused:
|
||||
logger.debug(
|
||||
"Timer '%s': paused, skipping tick",
|
||||
entry_point_id,
|
||||
)
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
time.monotonic() + interval_secs
|
||||
)
|
||||
await asyncio.sleep(interval_secs)
|
||||
continue
|
||||
|
||||
# Gate: skip tick if agent is actively working.
|
||||
# Gate: skip tick if ANY stream is actively working.
|
||||
_any_active = False
|
||||
_min_idle = float("inf")
|
||||
for _s in self._streams.values():
|
||||
if _s.active_execution_ids:
|
||||
_any_active = True
|
||||
_idle = _s.agent_idle_seconds
|
||||
if _idle < _min_idle:
|
||||
_min_idle = _idle
|
||||
logger.info(
|
||||
"Timer '%s': gate — active=%s, idle=%.1fs, timeout=%ds",
|
||||
entry_point_id,
|
||||
_any_active,
|
||||
_min_idle,
|
||||
idle_timeout,
|
||||
)
|
||||
if _any_active and _min_idle < idle_timeout:
|
||||
logger.info(
|
||||
"Timer '%s': agent actively working, skipping tick",
|
||||
entry_point_id,
|
||||
)
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
time.monotonic() + interval_secs
|
||||
)
|
||||
await asyncio.sleep(interval_secs)
|
||||
continue
|
||||
|
||||
self._timer_next_fire.pop(entry_point_id, None)
|
||||
try:
|
||||
ep_spec = self._entry_points.get(entry_point_id)
|
||||
is_isolated = ep_spec and ep_spec.isolation_level == "isolated"
|
||||
if is_isolated:
|
||||
if _persistent_session_id:
|
||||
session_state = {
|
||||
"resume_session_id": _persistent_session_id
|
||||
}
|
||||
else:
|
||||
session_state = None
|
||||
else:
|
||||
session_state = self._get_primary_session_state(
|
||||
exclude_entry_point=entry_point_id
|
||||
)
|
||||
# Gate: skip tick if no active session
|
||||
if session_state is None:
|
||||
logger.debug(
|
||||
"Timer '%s': no active session, skipping",
|
||||
entry_point_id,
|
||||
)
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
time.monotonic() + interval_secs
|
||||
)
|
||||
await asyncio.sleep(interval_secs)
|
||||
continue
|
||||
|
||||
exec_id = await self.trigger(
|
||||
entry_point_id,
|
||||
{
|
||||
"event": {
|
||||
"source": "timer",
|
||||
"reason": "scheduled",
|
||||
}
|
||||
},
|
||||
session_state=session_state,
|
||||
)
|
||||
if not _persistent_session_id and is_isolated:
|
||||
_persistent_session_id = exec_id
|
||||
logger.info(
|
||||
"Timer fired for entry point '%s' (next in %s min)",
|
||||
entry_point_id,
|
||||
mins,
|
||||
)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Timer trigger failed for '%s'",
|
||||
entry_point_id,
|
||||
exc_info=True,
|
||||
)
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
time.monotonic() + interval_secs
|
||||
)
|
||||
await asyncio.sleep(interval_secs)
|
||||
|
||||
return _timer_loop
|
||||
|
||||
task = asyncio.create_task(
|
||||
_make_timer(
|
||||
ep_id,
|
||||
interval,
|
||||
run_immediately,
|
||||
idle_timeout=float(tc.get("idle_timeout_seconds", 300)),
|
||||
)()
|
||||
)
|
||||
self._timer_tasks.append(task)
|
||||
logger.info(
|
||||
"Started timer for entry point '%s' every %s min%s",
|
||||
ep_id,
|
||||
interval,
|
||||
" (immediate first run)" if run_immediately else "",
|
||||
)
|
||||
|
||||
else:
|
||||
logger.warning(
|
||||
"Entry point '%s' has trigger_type='timer' "
|
||||
"but no 'cron' or valid 'interval_minutes' in trigger_config",
|
||||
ep_id,
|
||||
)
|
||||
|
||||
# Register primary graph
|
||||
self._graphs[self._graph_id] = _GraphRegistration(
|
||||
graph=self.graph,
|
||||
goal=self.goal,
|
||||
entry_points=dict(self._entry_points),
|
||||
streams=dict(self._streams),
|
||||
storage_subpath="",
|
||||
event_subscriptions=list(self._event_subscriptions),
|
||||
timer_tasks=list(self._timer_tasks),
|
||||
timer_next_fire=self._timer_next_fire,
|
||||
)
|
||||
await self._start_timers()
|
||||
|
||||
# Start skill hot-reload watcher (no-op if watchfiles not installed)
|
||||
await self._skills_manager.start_watching()
|
||||
@@ -818,6 +497,332 @@ class AgentHost:
|
||||
n_stages,
|
||||
)
|
||||
|
||||
async def _start_timers(self) -> None:
|
||||
"""Start timer-driven entry points (extracted from start())."""
|
||||
for ep_id, spec in self._entry_points.items():
|
||||
if spec.trigger_type != "timer":
|
||||
continue
|
||||
|
||||
tc = spec.trigger_config
|
||||
cron_expr = tc.get("cron")
|
||||
_raw_interval = tc.get("interval_minutes")
|
||||
interval = float(_raw_interval) if _raw_interval is not None else None
|
||||
run_immediately = tc.get("run_immediately", False)
|
||||
|
||||
if cron_expr:
|
||||
# Cron expression mode — takes priority over interval_minutes
|
||||
try:
|
||||
from croniter import croniter
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"croniter is required for cron-based entry points. "
|
||||
"Install it with: uv pip install croniter"
|
||||
) from e
|
||||
|
||||
try:
|
||||
if not croniter.is_valid(cron_expr):
|
||||
raise ValueError(f"Invalid cron expression: {cron_expr}")
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
"Entry point '%s' has invalid cron config: %s",
|
||||
ep_id,
|
||||
e,
|
||||
)
|
||||
continue
|
||||
|
||||
def _make_cron_timer(
|
||||
entry_point_id: str,
|
||||
expr: str,
|
||||
immediate: bool,
|
||||
idle_timeout: float = 300,
|
||||
):
|
||||
async def _cron_loop():
|
||||
from croniter import croniter
|
||||
|
||||
_persistent_session_id: str | None = None
|
||||
if not immediate:
|
||||
cron = croniter(expr, datetime.now())
|
||||
next_dt = cron.get_next(datetime)
|
||||
sleep_secs = (next_dt - datetime.now()).total_seconds()
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
time.monotonic() + sleep_secs
|
||||
)
|
||||
await asyncio.sleep(max(0, sleep_secs))
|
||||
while self._running:
|
||||
# Calculate next fire time upfront (used by skip paths too)
|
||||
cron = croniter(expr, datetime.now())
|
||||
next_dt = cron.get_next(datetime)
|
||||
sleep_secs = (next_dt - datetime.now()).total_seconds()
|
||||
|
||||
# Gate: skip tick if timers are explicitly paused
|
||||
if self._timers_paused:
|
||||
logger.debug(
|
||||
"Cron '%s': paused, skipping tick",
|
||||
entry_point_id,
|
||||
)
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
time.monotonic() + sleep_secs
|
||||
)
|
||||
await asyncio.sleep(max(0, sleep_secs))
|
||||
continue
|
||||
|
||||
# Gate: skip tick if ANY stream is actively working.
|
||||
# If the execution is idle (no LLM/tool activity
|
||||
# beyond idle_timeout) let the timer proceed —
|
||||
# execute() will cancel the stale execution.
|
||||
_any_active = False
|
||||
_min_idle = float("inf")
|
||||
for _s in self._streams.values():
|
||||
if _s.active_execution_ids:
|
||||
_any_active = True
|
||||
_idle = _s.agent_idle_seconds
|
||||
if _idle < _min_idle:
|
||||
_min_idle = _idle
|
||||
logger.info(
|
||||
"Cron '%s': gate — active=%s, idle=%.1fs, timeout=%ds",
|
||||
entry_point_id,
|
||||
_any_active,
|
||||
_min_idle,
|
||||
idle_timeout,
|
||||
)
|
||||
if _any_active and _min_idle < idle_timeout:
|
||||
logger.info(
|
||||
"Cron '%s': agent actively working, skipping tick",
|
||||
entry_point_id,
|
||||
)
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
time.monotonic() + sleep_secs
|
||||
)
|
||||
await asyncio.sleep(max(0, sleep_secs))
|
||||
continue
|
||||
|
||||
self._timer_next_fire.pop(entry_point_id, None)
|
||||
try:
|
||||
ep_spec = self._entry_points.get(entry_point_id)
|
||||
is_isolated = ep_spec and ep_spec.isolation_level == "isolated"
|
||||
if is_isolated:
|
||||
if _persistent_session_id:
|
||||
session_state = {
|
||||
"resume_session_id": _persistent_session_id
|
||||
}
|
||||
else:
|
||||
session_state = None
|
||||
else:
|
||||
session_state = self._get_primary_session_state(
|
||||
exclude_entry_point=entry_point_id
|
||||
)
|
||||
# Gate: skip tick if no active session
|
||||
if session_state is None:
|
||||
logger.debug(
|
||||
"Cron '%s': no active session, skipping",
|
||||
entry_point_id,
|
||||
)
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
time.monotonic() + sleep_secs
|
||||
)
|
||||
await asyncio.sleep(max(0, sleep_secs))
|
||||
continue
|
||||
|
||||
exec_id = await self.trigger(
|
||||
entry_point_id,
|
||||
{
|
||||
"event": {
|
||||
"source": "timer",
|
||||
"reason": "scheduled",
|
||||
}
|
||||
},
|
||||
session_state=session_state,
|
||||
)
|
||||
if not _persistent_session_id and is_isolated:
|
||||
_persistent_session_id = exec_id
|
||||
logger.info(
|
||||
"Cron fired for entry point '%s' (expr: %s)",
|
||||
entry_point_id,
|
||||
expr,
|
||||
)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Cron trigger failed for '%s'",
|
||||
entry_point_id,
|
||||
exc_info=True,
|
||||
)
|
||||
# Calculate next fire from now
|
||||
cron = croniter(expr, datetime.now())
|
||||
next_dt = cron.get_next(datetime)
|
||||
sleep_secs = (next_dt - datetime.now()).total_seconds()
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
time.monotonic() + sleep_secs
|
||||
)
|
||||
await asyncio.sleep(max(0, sleep_secs))
|
||||
|
||||
return _cron_loop
|
||||
|
||||
task = asyncio.create_task(
|
||||
_make_cron_timer(
|
||||
ep_id,
|
||||
cron_expr,
|
||||
run_immediately,
|
||||
idle_timeout=float(tc.get("idle_timeout_seconds", 300)),
|
||||
)()
|
||||
)
|
||||
self._timer_tasks.append(task)
|
||||
logger.info(
|
||||
"Started cron timer for entry point '%s' with expression '%s'%s",
|
||||
ep_id,
|
||||
cron_expr,
|
||||
" (immediate first run)" if run_immediately else "",
|
||||
)
|
||||
|
||||
elif interval and interval > 0:
|
||||
# Fixed interval mode (original behavior)
|
||||
def _make_timer(
|
||||
entry_point_id: str,
|
||||
mins: float,
|
||||
immediate: bool,
|
||||
idle_timeout: float = 300,
|
||||
):
|
||||
async def _timer_loop():
|
||||
interval_secs = mins * 60
|
||||
_persistent_session_id: str | None = None
|
||||
if not immediate:
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
time.monotonic() + interval_secs
|
||||
)
|
||||
await asyncio.sleep(interval_secs)
|
||||
while self._running:
|
||||
# Gate: skip tick if timers are explicitly paused
|
||||
if self._timers_paused:
|
||||
logger.debug(
|
||||
"Timer '%s': paused, skipping tick",
|
||||
entry_point_id,
|
||||
)
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
time.monotonic() + interval_secs
|
||||
)
|
||||
await asyncio.sleep(interval_secs)
|
||||
continue
|
||||
|
||||
# Gate: skip tick if agent is actively working.
|
||||
# Gate: skip tick if ANY stream is actively working.
|
||||
_any_active = False
|
||||
_min_idle = float("inf")
|
||||
for _s in self._streams.values():
|
||||
if _s.active_execution_ids:
|
||||
_any_active = True
|
||||
_idle = _s.agent_idle_seconds
|
||||
if _idle < _min_idle:
|
||||
_min_idle = _idle
|
||||
logger.info(
|
||||
"Timer '%s': gate — active=%s, idle=%.1fs, timeout=%ds",
|
||||
entry_point_id,
|
||||
_any_active,
|
||||
_min_idle,
|
||||
idle_timeout,
|
||||
)
|
||||
if _any_active and _min_idle < idle_timeout:
|
||||
logger.info(
|
||||
"Timer '%s': agent actively working, skipping tick",
|
||||
entry_point_id,
|
||||
)
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
time.monotonic() + interval_secs
|
||||
)
|
||||
await asyncio.sleep(interval_secs)
|
||||
continue
|
||||
|
||||
self._timer_next_fire.pop(entry_point_id, None)
|
||||
try:
|
||||
ep_spec = self._entry_points.get(entry_point_id)
|
||||
is_isolated = ep_spec and ep_spec.isolation_level == "isolated"
|
||||
if is_isolated:
|
||||
if _persistent_session_id:
|
||||
session_state = {
|
||||
"resume_session_id": _persistent_session_id
|
||||
}
|
||||
else:
|
||||
session_state = None
|
||||
else:
|
||||
session_state = self._get_primary_session_state(
|
||||
exclude_entry_point=entry_point_id
|
||||
)
|
||||
# Gate: skip tick if no active session
|
||||
if session_state is None:
|
||||
logger.debug(
|
||||
"Timer '%s': no active session, skipping",
|
||||
entry_point_id,
|
||||
)
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
time.monotonic() + interval_secs
|
||||
)
|
||||
await asyncio.sleep(interval_secs)
|
||||
continue
|
||||
|
||||
exec_id = await self.trigger(
|
||||
entry_point_id,
|
||||
{
|
||||
"event": {
|
||||
"source": "timer",
|
||||
"reason": "scheduled",
|
||||
}
|
||||
},
|
||||
session_state=session_state,
|
||||
)
|
||||
if not _persistent_session_id and is_isolated:
|
||||
_persistent_session_id = exec_id
|
||||
logger.info(
|
||||
"Timer fired for entry point '%s' (next in %s min)",
|
||||
entry_point_id,
|
||||
mins,
|
||||
)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Timer trigger failed for '%s'",
|
||||
entry_point_id,
|
||||
exc_info=True,
|
||||
)
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
time.monotonic() + interval_secs
|
||||
)
|
||||
await asyncio.sleep(interval_secs)
|
||||
|
||||
return _timer_loop
|
||||
|
||||
task = asyncio.create_task(
|
||||
_make_timer(
|
||||
ep_id,
|
||||
interval,
|
||||
run_immediately,
|
||||
idle_timeout=float(tc.get("idle_timeout_seconds", 300)),
|
||||
)()
|
||||
)
|
||||
self._timer_tasks.append(task)
|
||||
logger.info(
|
||||
"Started timer for entry point '%s' every %s min%s",
|
||||
ep_id,
|
||||
interval,
|
||||
" (immediate first run)" if run_immediately else "",
|
||||
)
|
||||
|
||||
else:
|
||||
logger.warning(
|
||||
"Entry point '%s' has trigger_type='timer' "
|
||||
"but no 'cron' or valid 'interval_minutes' in trigger_config",
|
||||
ep_id,
|
||||
)
|
||||
|
||||
# Register primary graph
|
||||
self._graphs[self._graph_id] = _GraphRegistration(
|
||||
graph=self.graph,
|
||||
goal=self.goal,
|
||||
entry_points=dict(self._entry_points),
|
||||
streams=dict(self._streams),
|
||||
storage_subpath="",
|
||||
event_subscriptions=list(self._event_subscriptions),
|
||||
timer_tasks=list(self._timer_tasks),
|
||||
timer_next_fire=self._timer_next_fire,
|
||||
)
|
||||
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the agent runtime and all streams."""
|
||||
if not self._running:
|
||||
@@ -902,45 +907,27 @@ class AgentHost:
|
||||
return self._streams.get(entry_point_id)
|
||||
|
||||
def _apply_pipeline_results(self) -> None:
|
||||
"""Extract tools/LLM/credentials/skills from pipeline stages.
|
||||
|
||||
Called after ``pipeline.initialize_all()`` so stages have finished
|
||||
their async setup (MCP connected, skills discovered, etc.).
|
||||
The host reads stage properties and updates its own state.
|
||||
"""
|
||||
"""Read typed attributes from pipeline stages after initialization."""
|
||||
for stage in self._pipeline.stages:
|
||||
stage_name = stage.__class__.__name__
|
||||
name = stage.__class__.__name__
|
||||
|
||||
# McpRegistryStage -> tools
|
||||
if hasattr(stage, "tool_registry") and stage.tool_registry is not None:
|
||||
if stage.tool_registry is not None:
|
||||
tools = list(stage.tool_registry.get_tools().values())
|
||||
executor = stage.tool_registry.get_executor()
|
||||
if tools:
|
||||
self._tools = tools
|
||||
self._tool_executor = executor
|
||||
logger.info(
|
||||
"Pipeline injected %d tools from %s",
|
||||
len(tools), stage_name,
|
||||
)
|
||||
self._tool_executor = stage.tool_registry.get_executor()
|
||||
logger.info("Pipeline: %d tools from %s", len(tools), name)
|
||||
|
||||
# LlmProviderStage -> LLM
|
||||
if hasattr(stage, "llm") and stage.llm is not None:
|
||||
if self._llm is None:
|
||||
self._llm = stage.llm
|
||||
logger.info(
|
||||
"Pipeline injected LLM from %s", stage_name,
|
||||
)
|
||||
if stage.llm is not None and self._llm is None:
|
||||
self._llm = stage.llm
|
||||
logger.info("Pipeline: LLM from %s", name)
|
||||
|
||||
# CredentialResolverStage -> accounts
|
||||
if hasattr(stage, "accounts_prompt") and stage.accounts_prompt:
|
||||
if stage.accounts_prompt:
|
||||
self._accounts_prompt = stage.accounts_prompt
|
||||
self._accounts_data = getattr(stage, "accounts_data", None)
|
||||
self._tool_provider_map = getattr(
|
||||
stage, "tool_provider_map", None,
|
||||
)
|
||||
self._accounts_data = stage.accounts_data
|
||||
self._tool_provider_map = stage.tool_provider_map
|
||||
|
||||
# SkillRegistryStage -> skills manager
|
||||
if hasattr(stage, "skills_manager") and stage.skills_manager is not None:
|
||||
if stage.skills_manager is not None:
|
||||
self._skills_manager = stage.skills_manager
|
||||
|
||||
|
||||
@@ -1940,96 +1927,3 @@ class AgentHost:
|
||||
# === CONVENIENCE FACTORY ===
|
||||
|
||||
|
||||
def create_agent_runtime(
|
||||
graph: "GraphSpec",
|
||||
goal: "Goal",
|
||||
storage_path: str | Path,
|
||||
entry_points: list[EntryPointSpec],
|
||||
llm: "LLMProvider | None" = None,
|
||||
tools: list["Tool"] | None = None,
|
||||
tool_executor: Callable | None = None,
|
||||
config: AgentRuntimeConfig | None = None,
|
||||
runtime_log_store: Any = None,
|
||||
enable_logging: bool = True,
|
||||
checkpoint_config: CheckpointConfig | None = None,
|
||||
graph_id: str | None = None,
|
||||
accounts_prompt: str = "",
|
||||
accounts_data: list[dict] | None = None,
|
||||
tool_provider_map: dict[str, str] | None = None,
|
||||
event_bus: "EventBus | None" = None,
|
||||
skills_manager_config: "SkillsManagerConfig | None" = None,
|
||||
# Deprecated — pass skills_manager_config instead.
|
||||
skills_catalog_prompt: str = "",
|
||||
protocols_prompt: str = "",
|
||||
skill_dirs: list[str] | None = None,
|
||||
pipeline_stages: "list[PipelineStage] | None" = None,
|
||||
) -> AgentHost:
|
||||
"""
|
||||
Create and configure an AgentHost with entry points.
|
||||
|
||||
Convenience factory that creates runtime and registers entry points.
|
||||
Runtime logging is enabled by default for observability.
|
||||
|
||||
Args:
|
||||
graph: Graph specification
|
||||
goal: Goal driving execution
|
||||
storage_path: Path for persistent storage
|
||||
entry_points: Entry point specifications
|
||||
llm: LLM provider
|
||||
tools: Available tools
|
||||
tool_executor: Tool executor function
|
||||
config: Runtime configuration
|
||||
runtime_log_store: Optional RuntimeLogStore for per-execution logging.
|
||||
If None and enable_logging=True, creates one automatically.
|
||||
enable_logging: Whether to enable runtime logging (default: True).
|
||||
Set to False to disable logging entirely.
|
||||
checkpoint_config: Optional checkpoint configuration for resumable sessions.
|
||||
If None, uses default checkpointing behavior.
|
||||
graph_id: Optional identifier for the primary graph (defaults to "primary").
|
||||
accounts_data: Raw account data for per-node prompt generation.
|
||||
tool_provider_map: Tool name to provider name mapping for account routing.
|
||||
event_bus: Optional external EventBus to share with other components.
|
||||
skills_catalog_prompt: Available skills catalog for system prompt.
|
||||
protocols_prompt: Default skill operational protocols for system prompt.
|
||||
skill_dirs: Skill base directories for Tier 3 resource access.
|
||||
skills_manager_config: Skill configuration — the runtime owns
|
||||
discovery, loading, and prompt renderation internally.
|
||||
skills_catalog_prompt: Deprecated. Pre-rendered skills catalog.
|
||||
protocols_prompt: Deprecated. Pre-rendered operational protocols.
|
||||
|
||||
Returns:
|
||||
Configured AgentRuntime (not yet started)
|
||||
"""
|
||||
# Auto-create runtime log store if logging is enabled and not provided
|
||||
if enable_logging and runtime_log_store is None:
|
||||
from framework.tracker.runtime_log_store import RuntimeLogStore
|
||||
|
||||
storage_path_obj = Path(storage_path) if isinstance(storage_path, str) else storage_path
|
||||
runtime_log_store = RuntimeLogStore(storage_path_obj / "runtime_logs")
|
||||
|
||||
runtime = AgentHost(
|
||||
graph=graph,
|
||||
goal=goal,
|
||||
storage_path=storage_path,
|
||||
llm=llm,
|
||||
tools=tools,
|
||||
tool_executor=tool_executor,
|
||||
config=config,
|
||||
runtime_log_store=runtime_log_store,
|
||||
checkpoint_config=checkpoint_config,
|
||||
graph_id=graph_id,
|
||||
accounts_prompt=accounts_prompt,
|
||||
accounts_data=accounts_data,
|
||||
tool_provider_map=tool_provider_map,
|
||||
event_bus=event_bus,
|
||||
skills_manager_config=skills_manager_config,
|
||||
skills_catalog_prompt=skills_catalog_prompt,
|
||||
protocols_prompt=protocols_prompt,
|
||||
skill_dirs=skill_dirs,
|
||||
pipeline_stages=pipeline_stages,
|
||||
)
|
||||
|
||||
for spec in entry_points:
|
||||
runtime.register_entry_point(spec)
|
||||
|
||||
return runtime
|
||||
|
||||
@@ -25,9 +25,8 @@ from framework.orchestrator.node import NodeSpec
|
||||
from framework.llm.provider import LLMProvider, Tool
|
||||
from framework.loader.preload_validation import run_preload_validation
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
from framework.host.agent_host import AgentHost, AgentRuntimeConfig, create_agent_runtime
|
||||
from framework.host.agent_host import AgentHost, AgentRuntimeConfig
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
from framework.tracker.runtime_log_store import RuntimeLogStore
|
||||
from framework.tools.flowchart_utils import generate_fallback_flowchart
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1556,60 +1555,6 @@ class AgentLoader:
|
||||
}
|
||||
return self._tool_registry.register_mcp_server(server_config)
|
||||
|
||||
def _load_mcp_servers_from_config(self, config_path: Path) -> None:
|
||||
"""Load and register MCP servers from a configuration file."""
|
||||
self._tool_registry.load_mcp_config(config_path)
|
||||
|
||||
def _load_registry_mcp_servers(self, agent_path: Path) -> None:
|
||||
"""Load and register MCP servers selected via ``mcp_registry.json``."""
|
||||
registry_json = agent_path / "mcp_registry.json"
|
||||
if registry_json.is_file():
|
||||
self._tool_registry.set_mcp_registry_agent_path(agent_path)
|
||||
else:
|
||||
self._tool_registry.set_mcp_registry_agent_path(None)
|
||||
|
||||
from framework.loader.mcp_registry import MCPRegistry
|
||||
|
||||
try:
|
||||
registry = MCPRegistry()
|
||||
registry.initialize()
|
||||
server_configs, selection_max_tools = registry.load_agent_selection(agent_path)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to load MCP registry servers for '%s': %s",
|
||||
agent_path.name,
|
||||
exc,
|
||||
)
|
||||
return
|
||||
|
||||
if not server_configs:
|
||||
return
|
||||
|
||||
results = self._tool_registry.load_registry_servers(
|
||||
server_configs,
|
||||
preserve_existing_tools=True,
|
||||
log_collisions=True,
|
||||
max_tools=selection_max_tools,
|
||||
)
|
||||
loaded = [result for result in results if result["status"] == "loaded"]
|
||||
skipped = [result for result in results if result["status"] != "loaded"]
|
||||
|
||||
logger.info(
|
||||
"Loaded %d/%d MCP registry server(s) for agent '%s'",
|
||||
len(loaded),
|
||||
len(results),
|
||||
agent_path.name,
|
||||
)
|
||||
if skipped:
|
||||
logger.info(
|
||||
"Skipped MCP registry servers for agent '%s': %s",
|
||||
agent_path.name,
|
||||
[
|
||||
{"server": result["server"], "reason": result["skipped_reason"]}
|
||||
for result in skipped
|
||||
],
|
||||
)
|
||||
|
||||
def set_approval_callback(self, callback: Callable) -> None:
|
||||
"""
|
||||
Set a callback for human-in-the-loop approval during execution.
|
||||
@@ -1701,66 +1646,37 @@ class AgentLoader:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._setup_agent_runtime_with_pipeline(
|
||||
pipeline_stages=pipeline_stages,
|
||||
event_bus=event_bus,
|
||||
)
|
||||
|
||||
def _setup_agent_runtime_with_pipeline(
|
||||
self,
|
||||
pipeline_stages: list,
|
||||
event_bus=None,
|
||||
) -> None:
|
||||
"""Create AgentHost with pipeline stages."""
|
||||
# Create AgentHost directly (no wrapper)
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
from framework.orchestrator.checkpoint_config import CheckpointConfig
|
||||
from framework.tracker.runtime_log_store import RuntimeLogStore
|
||||
|
||||
entry_points = []
|
||||
if self.graph.entry_node:
|
||||
entry_points.append(
|
||||
EntryPointSpec(
|
||||
id="default",
|
||||
name="Default",
|
||||
entry_node=self.graph.entry_node,
|
||||
trigger_type="manual",
|
||||
isolation_level="shared",
|
||||
),
|
||||
)
|
||||
|
||||
log_store = RuntimeLogStore(
|
||||
base_path=self._storage_path / "runtime_logs",
|
||||
)
|
||||
checkpoint_config = CheckpointConfig(
|
||||
enabled=True,
|
||||
checkpoint_on_node_start=False,
|
||||
checkpoint_on_node_complete=True,
|
||||
checkpoint_max_age_days=7,
|
||||
async_checkpoint=True,
|
||||
)
|
||||
|
||||
runtime_config = None
|
||||
if self.runtime_config is not None:
|
||||
from framework.host.agent_host import AgentRuntimeConfig
|
||||
|
||||
if isinstance(self.runtime_config, AgentRuntimeConfig):
|
||||
runtime_config = self.runtime_config
|
||||
|
||||
|
||||
self._agent_runtime = create_agent_runtime(
|
||||
self._agent_runtime = AgentHost(
|
||||
graph=self.graph,
|
||||
goal=self.goal,
|
||||
storage_path=self._storage_path,
|
||||
entry_points=entry_points,
|
||||
llm=None, # Injected by LlmProviderStage
|
||||
tools=[], # Injected by McpRegistryStage
|
||||
tool_executor=None, # Injected by McpRegistryStage
|
||||
runtime_log_store=log_store,
|
||||
checkpoint_config=checkpoint_config,
|
||||
config=runtime_config,
|
||||
runtime_log_store=RuntimeLogStore(
|
||||
base_path=self._storage_path / "runtime_logs",
|
||||
),
|
||||
checkpoint_config=CheckpointConfig(
|
||||
enabled=True,
|
||||
checkpoint_on_node_complete=True,
|
||||
checkpoint_max_age_days=7,
|
||||
async_checkpoint=True,
|
||||
),
|
||||
graph_id=self.graph.id or self.agent_path.name,
|
||||
event_bus=event_bus,
|
||||
pipeline_stages=pipeline_stages,
|
||||
)
|
||||
self._agent_runtime.register_entry_point(
|
||||
EntryPointSpec(
|
||||
id="default",
|
||||
name="Default",
|
||||
entry_node=self.graph.entry_node,
|
||||
trigger_type="manual",
|
||||
isolation_level="shared",
|
||||
),
|
||||
)
|
||||
self._agent_runtime.intro_message = self.intro_message
|
||||
|
||||
def _get_api_key_env_var(self, model: str) -> str | None:
|
||||
@@ -2190,7 +2106,7 @@ class AgentLoader:
|
||||
except ImportError:
|
||||
# aden_tools not installed - fall back to direct check
|
||||
has_llm_nodes = any(
|
||||
node.node_type in ("event_loop", "gcu") for node in self.graph.nodes
|
||||
node.node_type == "event_loop" for node in self.graph.nodes
|
||||
)
|
||||
if has_llm_nodes:
|
||||
api_key_env = self._get_api_key_env_var(self.model)
|
||||
|
||||
@@ -607,7 +607,7 @@ class NodeWorker:
|
||||
return self._node_impl
|
||||
|
||||
# Auto-create EventLoopNode
|
||||
if self.node_spec.node_type in ("event_loop", "gcu"):
|
||||
if self.node_spec.node_type == "event_loop":
|
||||
from framework.agent_loop.internals.types import LoopConfig
|
||||
from framework.agent_loop.agent_loop import AgentLoop
|
||||
from framework.orchestrator.node import warn_if_deprecated_client_facing
|
||||
|
||||
@@ -727,7 +727,6 @@ class Orchestrator:
|
||||
|
||||
VALID_NODE_TYPES = {
|
||||
"event_loop",
|
||||
"gcu",
|
||||
}
|
||||
# Node types removed in v0.5 — provide migration guidance
|
||||
REMOVED_NODE_TYPES = {
|
||||
@@ -773,7 +772,7 @@ class Orchestrator:
|
||||
)
|
||||
|
||||
# Create based on type
|
||||
if node_spec.node_type in ("event_loop", "gcu"):
|
||||
if node_spec.node_type == "event_loop":
|
||||
# Auto-create EventLoopNode with sensible defaults.
|
||||
# Custom configs can still be pre-registered via node_registry.
|
||||
from framework.agent_loop.agent_loop import AgentLoop, LoopConfig
|
||||
|
||||
@@ -193,13 +193,10 @@ def build_system_prompt(spec: NodePromptSpec) -> str:
|
||||
if spec.narrative:
|
||||
parts.append(f"\n--- Context (what has happened so far) ---\n{spec.narrative}")
|
||||
|
||||
if not False and spec.node_type in ("event_loop", "gcu") and spec.output_keys:
|
||||
if not False and spec.node_type == "event_loop" and spec.output_keys:
|
||||
parts.append(f"\n{EXECUTION_SCOPE_PREAMBLE}")
|
||||
|
||||
if spec.node_type == "gcu":
|
||||
from framework.orchestrator.gcu import GCU_BROWSER_SYSTEM_PROMPT
|
||||
|
||||
parts.append(f"\n{GCU_BROWSER_SYSTEM_PROMPT}")
|
||||
|
||||
if spec.focus_prompt:
|
||||
parts.append(f"\n--- Current Focus ---\n{spec.focus_prompt}")
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any, Literal
|
||||
|
||||
|
||||
class PipelineRejectedError(Exception):
|
||||
"""Raised by ``AgentRuntime.trigger`` when a stage rejects the request."""
|
||||
"""Raised by ``AgentHost.trigger`` when a stage rejects the request."""
|
||||
|
||||
def __init__(self, stage_name: str, reason: str) -> None:
|
||||
super().__init__(f"Pipeline rejected by {stage_name}: {reason}")
|
||||
@@ -18,12 +18,7 @@ class PipelineRejectedError(Exception):
|
||||
|
||||
@dataclass
|
||||
class PipelineContext:
|
||||
"""Carries request data through the pipeline.
|
||||
|
||||
Stages can mutate ``metadata`` to pass information downstream -- e.g. a
|
||||
cost-estimation stage might write ``metadata["estimated_cost"]`` so a
|
||||
budget-guard stage later in the chain can reject requests over budget.
|
||||
"""
|
||||
"""Carries request data through the pipeline."""
|
||||
|
||||
entry_point_id: str
|
||||
input_data: dict[str, Any]
|
||||
@@ -44,25 +39,38 @@ class PipelineResult:
|
||||
class PipelineStage(ABC):
|
||||
"""Base class for all middleware stages.
|
||||
|
||||
Subclasses implement :meth:`process`. The class attribute ``order``
|
||||
controls stage ordering (lower runs first; 100 is the default).
|
||||
Stages may also implement :meth:`initialize` for one-time setup and
|
||||
:meth:`post_process` to decorate the execution result.
|
||||
Infrastructure stages (LLM, MCP, credentials, skills) set typed
|
||||
attributes during ``initialize()`` that the host reads after all
|
||||
stages have initialized. Request-level stages (rate limit, input
|
||||
validation, cost guard) implement ``process()``.
|
||||
|
||||
Attributes set by infrastructure stages:
|
||||
llm: LLM provider instance (set by LlmProviderStage)
|
||||
tool_registry: ToolRegistry with discovered MCP tools (set by McpRegistryStage)
|
||||
accounts_prompt: Connected accounts system prompt block (set by CredentialResolverStage)
|
||||
accounts_data: Raw account info list (set by CredentialResolverStage)
|
||||
tool_provider_map: Tool name -> provider mapping (set by CredentialResolverStage)
|
||||
skills_manager: SkillsManager instance (set by SkillRegistryStage)
|
||||
"""
|
||||
|
||||
order: int = 100
|
||||
|
||||
# Infrastructure stage outputs -- typed so _apply_pipeline_results
|
||||
# doesn't need hasattr() sniffing.
|
||||
llm: Any = None
|
||||
tool_registry: Any = None
|
||||
accounts_prompt: str = ""
|
||||
accounts_data: list[dict] | None = None
|
||||
tool_provider_map: dict[str, str] | None = None
|
||||
skills_manager: Any = None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Called once when the runtime starts."""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
async def process(self, ctx: PipelineContext) -> PipelineResult:
|
||||
"""Process the incoming request.
|
||||
|
||||
Return a :class:`PipelineResult` with action ``continue``, ``reject``,
|
||||
or ``transform``.
|
||||
"""
|
||||
"""Process the incoming request."""
|
||||
|
||||
async def post_process(self, ctx: PipelineContext, result: Any) -> Any:
|
||||
"""Optional post-execution hook. Default: pass-through."""
|
||||
|
||||
@@ -119,12 +119,11 @@ def classify_flowchart_node(
|
||||
return FLOWCHART_REMAP[explicit]
|
||||
|
||||
node_id = node["id"]
|
||||
node_type = node.get("node_type", "event_loop")
|
||||
node_tools = set(node.get("tools") or [])
|
||||
desc = (node.get("description") or "").lower()
|
||||
|
||||
# GCU / browser automation nodes → hexagon
|
||||
if node_type == "gcu":
|
||||
if False: # gcu removed
|
||||
return "browser"
|
||||
|
||||
# Entry node (first node or no incoming edges) → start terminator
|
||||
|
||||
@@ -68,7 +68,7 @@ def _node_to_dict(node: Any) -> dict:
|
||||
tools_list = list(node.tools) if node.tools else []
|
||||
if tools_list:
|
||||
d["tools"] = {"policy": "explicit", "allowed": tools_list}
|
||||
elif node.node_type == "gcu":
|
||||
elif False: # gcu removed
|
||||
d["tools"] = {"policy": "all"}
|
||||
else:
|
||||
d["tools"] = {"policy": "none"}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Queen lifecycle tools -- split into per-tool modules.
|
||||
|
||||
The main entry point is still ``register_queen_lifecycle_tools()`` in
|
||||
``queen_lifecycle_tools.py``. This package provides the shared context
|
||||
and individual tool registration functions.
|
||||
"""
|
||||
|
||||
from framework.tools.queen_lifecycle.context import QueenToolContext
|
||||
|
||||
__all__ = ["QueenToolContext"]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Shared context for queen lifecycle tools.
|
||||
|
||||
All queen tools receive this context instead of closing over
|
||||
individual variables from the registration function.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueenToolContext:
|
||||
"""Shared state passed to all queen lifecycle tool implementations."""
|
||||
|
||||
session: Any # Session or WorkerSessionAdapter
|
||||
session_manager: Any | None = None
|
||||
manager_session_id: str | None = None
|
||||
phase_state: Any | None = None # QueenPhaseState
|
||||
registry: Any = None # ToolRegistry
|
||||
|
||||
def get_runtime(self):
|
||||
"""Get current graph runtime from session (late-binding)."""
|
||||
return getattr(self.session, "graph_runtime", None)
|
||||
|
||||
def update_meta(self, updates: dict) -> None:
|
||||
"""Update session metadata JSON."""
|
||||
if self.session_manager is None or self.manager_session_id is None:
|
||||
return
|
||||
try:
|
||||
srv_session = self.session_manager.get_session(self.manager_session_id)
|
||||
if srv_session is None:
|
||||
return
|
||||
meta_path = getattr(srv_session, "meta_path", None)
|
||||
if meta_path is None:
|
||||
return
|
||||
import pathlib
|
||||
|
||||
meta_file = pathlib.Path(meta_path)
|
||||
if meta_file.exists():
|
||||
data = json.loads(meta_file.read_text(encoding="utf-8"))
|
||||
else:
|
||||
data = {}
|
||||
data.update(updates)
|
||||
meta_file.write_text(json.dumps(data, indent=2) + "\n")
|
||||
except Exception:
|
||||
logger.debug("Failed to update session meta", exc_info=True)
|
||||
@@ -122,7 +122,7 @@ def tui(verbose: bool, debug: bool) -> None:
|
||||
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
from framework.host.agent_host import create_agent_runtime
|
||||
from framework.host.agent_host import AgentHost
|
||||
from framework.host.event_bus import EventBus
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
|
||||
@@ -150,7 +150,7 @@ def tui(verbose: bool, debug: bool) -> None:
|
||||
tool_executor = agent._tool_registry.get_executor()
|
||||
graph = agent._build_graph()
|
||||
|
||||
runtime = create_agent_runtime(
|
||||
runtime = AgentHost(
|
||||
graph=graph,
|
||||
goal=agent.goal,
|
||||
storage_path=storage_path,
|
||||
|
||||
@@ -75,7 +75,7 @@ def tui(verbose, debug):
|
||||
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
from framework.host.agent_host import create_agent_runtime
|
||||
from framework.host.agent_host import AgentHost
|
||||
from framework.host.event_bus import EventBus
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
|
||||
@@ -103,7 +103,7 @@ def tui(verbose, debug):
|
||||
tool_executor = agent._tool_registry.get_executor()
|
||||
graph = agent._build_graph()
|
||||
|
||||
runtime = create_agent_runtime(
|
||||
runtime = AgentHost(
|
||||
graph=graph,
|
||||
goal=agent.goal,
|
||||
storage_path=storage_path,
|
||||
|
||||
@@ -8,7 +8,7 @@ from framework.orchestrator.orchestrator import ExecutionResult
|
||||
from framework.orchestrator.checkpoint_config import CheckpointConfig
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
from framework.host.agent_host import AgentHost, create_agent_runtime
|
||||
from framework.host.agent_host import AgentHost
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
|
||||
from .config import default_config, metadata
|
||||
@@ -244,7 +244,7 @@ class DeepResearchAgent:
|
||||
)
|
||||
]
|
||||
|
||||
self._agent_runtime = create_agent_runtime(
|
||||
self._agent_runtime = AgentHost(
|
||||
graph=self._graph,
|
||||
goal=self.goal,
|
||||
storage_path=self._storage_path,
|
||||
|
||||
@@ -84,7 +84,7 @@ def tui(mock, verbose, debug):
|
||||
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
from framework.host.agent_host import create_agent_runtime
|
||||
from framework.host.agent_host import AgentHost
|
||||
from framework.host.event_bus import EventBus
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
|
||||
@@ -118,7 +118,7 @@ def tui(mock, verbose, debug):
|
||||
tool_executor = agent._tool_registry.get_executor()
|
||||
graph = agent._build_graph()
|
||||
|
||||
runtime = create_agent_runtime(
|
||||
runtime = AgentHost(
|
||||
graph=graph,
|
||||
goal=agent.goal,
|
||||
storage_path=storage_path,
|
||||
|
||||
@@ -8,7 +8,7 @@ from framework.orchestrator.edge import GraphSpec
|
||||
from framework.orchestrator.orchestrator import ExecutionResult, Orchestrator
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
from framework.host.agent_host import create_agent_runtime
|
||||
from framework.host.agent_host import AgentHost
|
||||
from framework.host.event_bus import EventBus
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
|
||||
@@ -264,7 +264,7 @@ class EmailInboxManagementAgent:
|
||||
),
|
||||
]
|
||||
|
||||
self._agent_runtime = create_agent_runtime(
|
||||
self._agent_runtime = AgentHost(
|
||||
graph=self._graph,
|
||||
goal=self.goal,
|
||||
storage_path=self._storage_path,
|
||||
|
||||
@@ -52,7 +52,7 @@ def tui():
|
||||
from framework.tui.app import AdenTUI
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
from framework.host.agent_host import create_agent_runtime
|
||||
from framework.host.agent_host import AgentHost
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
|
||||
async def run_tui():
|
||||
@@ -68,7 +68,7 @@ def tui():
|
||||
api_key=agent.config.api_key,
|
||||
api_base=agent.config.api_base,
|
||||
)
|
||||
runtime = create_agent_runtime(
|
||||
runtime = AgentHost(
|
||||
graph=agent._build_graph(),
|
||||
goal=agent.goal,
|
||||
storage_path=storage,
|
||||
|
||||
@@ -8,7 +8,7 @@ from framework.orchestrator.orchestrator import ExecutionResult
|
||||
from framework.orchestrator.checkpoint_config import CheckpointConfig
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
from framework.host.agent_host import create_agent_runtime
|
||||
from framework.host.agent_host import AgentHost
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
|
||||
from .config import default_config, metadata
|
||||
@@ -159,7 +159,7 @@ class EmailReplyAgent:
|
||||
tools = list(self._tool_registry.get_tools().values())
|
||||
tool_executor = self._tool_registry.get_executor()
|
||||
self._graph = self._build_graph()
|
||||
self._agent_runtime = create_agent_runtime(
|
||||
self._agent_runtime = AgentHost(
|
||||
graph=self._graph,
|
||||
goal=self.goal,
|
||||
storage_path=self._storage_path,
|
||||
|
||||
@@ -76,7 +76,7 @@ def tui(mock, verbose, debug):
|
||||
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
from framework.host.agent_host import create_agent_runtime
|
||||
from framework.host.agent_host import AgentHost
|
||||
from framework.host.event_bus import EventBus
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
|
||||
@@ -106,7 +106,7 @@ def tui(mock, verbose, debug):
|
||||
tool_executor = agent._tool_registry.get_executor()
|
||||
graph = agent._build_graph()
|
||||
|
||||
runtime = create_agent_runtime(
|
||||
runtime = AgentHost(
|
||||
graph=graph,
|
||||
goal=agent.goal,
|
||||
storage_path=storage_path,
|
||||
|
||||
@@ -8,7 +8,7 @@ from framework.orchestrator.orchestrator import ExecutionResult
|
||||
from framework.orchestrator.checkpoint_config import CheckpointConfig
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
from framework.host.agent_host import AgentHost, create_agent_runtime
|
||||
from framework.host.agent_host import AgentHost
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
|
||||
from .config import default_config
|
||||
@@ -224,7 +224,7 @@ class JobHunterAgent:
|
||||
)
|
||||
]
|
||||
|
||||
self._agent_runtime = create_agent_runtime(
|
||||
self._agent_runtime = AgentHost(
|
||||
graph=self._graph,
|
||||
goal=self.goal,
|
||||
storage_path=self._storage_path,
|
||||
|
||||
@@ -7,7 +7,7 @@ from framework.orchestrator.orchestrator import ExecutionResult
|
||||
from framework.orchestrator.checkpoint_config import CheckpointConfig
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
from framework.host.agent_host import create_agent_runtime
|
||||
from framework.host.agent_host import AgentHost
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
|
||||
from .config import default_config, metadata
|
||||
@@ -125,7 +125,7 @@ class LocalBusinessExtractor:
|
||||
tools = list(self._tool_registry.get_tools().values())
|
||||
tool_executor = self._tool_registry.get_executor()
|
||||
self._graph = self._build_graph()
|
||||
self._agent_runtime = create_agent_runtime(
|
||||
self._agent_runtime = AgentHost(
|
||||
graph=self._graph,
|
||||
goal=self.goal,
|
||||
storage_path=self._storage_path,
|
||||
|
||||
@@ -55,7 +55,7 @@ def tui():
|
||||
from framework.tui.app import AdenTUI
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
from framework.host.agent_host import create_agent_runtime
|
||||
from framework.host.agent_host import AgentHost
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
|
||||
async def run_tui():
|
||||
@@ -71,7 +71,7 @@ def tui():
|
||||
api_key=agent.config.api_key,
|
||||
api_base=agent.config.api_base,
|
||||
)
|
||||
runtime = create_agent_runtime(
|
||||
runtime = AgentHost(
|
||||
graph=agent._build_graph(),
|
||||
goal=agent.goal,
|
||||
storage_path=storage,
|
||||
|
||||
@@ -8,7 +8,7 @@ from framework.orchestrator.orchestrator import ExecutionResult
|
||||
from framework.orchestrator.checkpoint_config import CheckpointConfig
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
from framework.host.agent_host import create_agent_runtime
|
||||
from framework.host.agent_host import AgentHost
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
|
||||
from .config import default_config, metadata
|
||||
@@ -165,7 +165,7 @@ class MeetingScheduler:
|
||||
tools = list(self._tool_registry.get_tools().values())
|
||||
tool_executor = self._tool_registry.get_executor()
|
||||
self._graph = self._build_graph()
|
||||
self._agent_runtime = create_agent_runtime(
|
||||
self._agent_runtime = AgentHost(
|
||||
graph=self._graph,
|
||||
goal=self.goal,
|
||||
storage_path=self._storage_path,
|
||||
|
||||
@@ -8,7 +8,7 @@ from framework.orchestrator.edge import AsyncEntryPointSpec, GraphSpec
|
||||
from framework.orchestrator.orchestrator import ExecutionResult
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
from framework.host.agent_host import AgentHost, create_agent_runtime
|
||||
from framework.host.agent_host import AgentHost
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
|
||||
from .config import default_config, metadata
|
||||
@@ -265,7 +265,7 @@ class SDRAgent:
|
||||
),
|
||||
]
|
||||
|
||||
self._agent_runtime = create_agent_runtime(
|
||||
self._agent_runtime = AgentHost(
|
||||
graph=self._graph,
|
||||
goal=self.goal,
|
||||
storage_path=self._storage_path,
|
||||
|
||||
@@ -74,7 +74,7 @@ def tui(verbose, debug):
|
||||
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
from framework.host.agent_host import create_agent_runtime
|
||||
from framework.host.agent_host import AgentHost
|
||||
from framework.host.event_bus import EventBus
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
|
||||
@@ -101,7 +101,7 @@ def tui(verbose, debug):
|
||||
tool_executor = agent._tool_registry.get_executor()
|
||||
graph = agent._build_graph()
|
||||
|
||||
runtime = create_agent_runtime(
|
||||
runtime = AgentHost(
|
||||
graph=graph,
|
||||
goal=agent.goal,
|
||||
storage_path=storage_path,
|
||||
|
||||
@@ -8,7 +8,7 @@ from framework.orchestrator.orchestrator import ExecutionResult
|
||||
from framework.orchestrator.checkpoint_config import CheckpointConfig
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
from framework.host.agent_host import create_agent_runtime
|
||||
from framework.host.agent_host import AgentHost
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
|
||||
from .config import default_config, metadata
|
||||
@@ -149,7 +149,7 @@ class TwitterNewsAgent:
|
||||
tools = list(self._tool_registry.get_tools().values())
|
||||
tool_executor = self._tool_registry.get_executor()
|
||||
self._graph = self._build_graph()
|
||||
self._agent_runtime = create_agent_runtime(
|
||||
self._agent_runtime = AgentHost(
|
||||
graph=self._graph,
|
||||
goal=self.goal,
|
||||
storage_path=self._storage_path,
|
||||
|
||||
@@ -77,7 +77,7 @@ def tui(mock, verbose, debug):
|
||||
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
from framework.host.agent_host import create_agent_runtime
|
||||
from framework.host.agent_host import AgentHost
|
||||
from framework.host.event_bus import EventBus
|
||||
from framework.host.execution_manager import EntryPointSpec
|
||||
|
||||
@@ -107,7 +107,7 @@ def tui(mock, verbose, debug):
|
||||
tool_executor = agent._tool_registry.get_executor()
|
||||
graph = agent._build_graph()
|
||||
|
||||
runtime = create_agent_runtime(
|
||||
runtime = AgentHost(
|
||||
graph=graph,
|
||||
goal=agent.goal,
|
||||
storage_path=storage_path,
|
||||
|
||||
@@ -1478,13 +1478,11 @@ def run_agent_tests(
|
||||
def validate_agent_package(agent_name: str) -> str:
|
||||
"""Run structural validation checks on a built agent package in one call.
|
||||
|
||||
Executes 5 steps and reports all results (does not stop on first failure):
|
||||
1. Class validation — checks graph structure and entry_points contract
|
||||
2. Node completeness — every NodeSpec in nodes/ must be in the nodes list,
|
||||
and GCU nodes must be referenced in a parent's sub_agents
|
||||
3. Graph validation — loads the agent graph without credential checks
|
||||
4. Tool validation — checks declared tools exist in MCP servers
|
||||
5. Tests — runs the agent's pytest suite
|
||||
Executes validation steps and reports all results:
|
||||
1. Schema validation — loads agent.json via load_agent_config
|
||||
2. Graph validation — loads the agent graph via AgentLoader
|
||||
3. Tool validation — checks declared tools exist in MCP servers
|
||||
4. Tests — runs the agent's pytest suite (skipped if no tests/)
|
||||
|
||||
Note: Credential validation is intentionally skipped here (building phase).
|
||||
Credentials are validated at run time by run_agent_with_input() preflight.
|
||||
@@ -1522,19 +1520,8 @@ def validate_agent_package(agent_name: str) -> str:
|
||||
pathlib.Path('exports/{agent_name}/agent.json').read_text()
|
||||
)
|
||||
g, goal = load_agent_config(data)
|
||||
# Check GCU sub_agent references
|
||||
sub_refs = set()
|
||||
for n in g.nodes:
|
||||
for sa in getattr(n, 'sub_agents', []) or []:
|
||||
sub_refs.add(sa)
|
||||
errors = []
|
||||
for n in g.nodes:
|
||||
if n.node_type == 'gcu' and n.id not in sub_refs:
|
||||
errors.append(
|
||||
f"GCU node '{{n.id}}' not in any node's sub_agents"
|
||||
)
|
||||
print(json.dumps({{
|
||||
'valid': len(errors) == 0,
|
||||
'valid': True,
|
||||
'nodes': len(g.nodes),
|
||||
'edges': len(g.edges),
|
||||
'entry': g.entry_node,
|
||||
|
||||
Reference in New Issue
Block a user