382 lines
12 KiB
Python
382 lines
12 KiB
Python
"""
|
|
Runtime Core - The interface agents use to record their behavior.
|
|
|
|
This is designed to make it EASY for agents to record decisions in a way
|
|
that Builder can analyze. The agent calls simple methods, and the runtime
|
|
handles all the structured logging.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from typing import Any
|
|
from pathlib import Path
|
|
import logging
|
|
import uuid
|
|
|
|
from framework.schemas.decision import Decision, Option, Outcome, DecisionType
|
|
from framework.schemas.run import Run, RunStatus
|
|
from framework.storage.backend import FileStorage
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class Runtime:
|
|
"""
|
|
The runtime environment that agents execute within.
|
|
|
|
Usage:
|
|
runtime = Runtime("/path/to/storage")
|
|
|
|
# Start a run
|
|
run_id = runtime.start_run("goal_123", "Qualify sales leads")
|
|
|
|
# Record a decision
|
|
decision_id = runtime.decide(
|
|
node_id="lead-qualifier",
|
|
intent="Determine if lead has budget",
|
|
options=[
|
|
{"id": "ask", "description": "Ask the lead directly"},
|
|
{"id": "infer", "description": "Infer from company size"},
|
|
],
|
|
chosen="infer",
|
|
reasoning="Company data is available, asking would be slower"
|
|
)
|
|
|
|
# Record the outcome
|
|
runtime.record_outcome(
|
|
decision_id=decision_id,
|
|
success=True,
|
|
result={"has_budget": True, "estimated": "$50k"},
|
|
summary="Inferred budget of $50k from company revenue"
|
|
)
|
|
|
|
# End the run
|
|
runtime.end_run(success=True, narrative="Qualified 10 leads successfully")
|
|
"""
|
|
|
|
def __init__(self, storage_path: str | Path):
|
|
self.storage = FileStorage(storage_path)
|
|
self._current_run: Run | None = None
|
|
self._current_node: str = "unknown"
|
|
|
|
# === RUN LIFECYCLE ===
|
|
|
|
def start_run(
|
|
self,
|
|
goal_id: str,
|
|
goal_description: str = "",
|
|
input_data: dict[str, Any] | None = None,
|
|
) -> str:
|
|
"""
|
|
Start a new run.
|
|
|
|
Args:
|
|
goal_id: The ID of the goal being pursued
|
|
goal_description: Human-readable description of the goal
|
|
input_data: Initial input to the run
|
|
|
|
Returns:
|
|
The run ID
|
|
"""
|
|
run_id = f"run_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
|
|
|
|
self._current_run = Run(
|
|
id=run_id,
|
|
goal_id=goal_id,
|
|
goal_description=goal_description,
|
|
input_data=input_data or {},
|
|
)
|
|
|
|
return run_id
|
|
|
|
def end_run(
|
|
self,
|
|
success: bool,
|
|
narrative: str = "",
|
|
output_data: dict[str, Any] | None = None,
|
|
) -> None:
|
|
"""
|
|
End the current run.
|
|
|
|
Args:
|
|
success: Whether the run achieved its goal
|
|
narrative: Human-readable summary of what happened
|
|
output_data: Final output of the run
|
|
"""
|
|
if self._current_run is None:
|
|
# Gracefully handle case where run was already ended or never started
|
|
# This can happen during exception handling cascades
|
|
logger.warning("end_run called but no run in progress (already ended or never started)")
|
|
return
|
|
|
|
status = RunStatus.COMPLETED if success else RunStatus.FAILED
|
|
self._current_run.output_data = output_data or {}
|
|
self._current_run.complete(status, narrative)
|
|
|
|
# Save to storage
|
|
self.storage.save_run(self._current_run)
|
|
self._current_run = None
|
|
|
|
def set_node(self, node_id: str) -> None:
|
|
"""Set the current node context for subsequent decisions."""
|
|
self._current_node = node_id
|
|
|
|
@property
|
|
def current_run(self) -> Run | None:
|
|
"""Get the current run (for inspection)."""
|
|
return self._current_run
|
|
|
|
# === DECISION RECORDING ===
|
|
|
|
def decide(
|
|
self,
|
|
intent: str,
|
|
options: list[dict[str, Any]],
|
|
chosen: str,
|
|
reasoning: str,
|
|
node_id: str | None = None,
|
|
decision_type: DecisionType = DecisionType.CUSTOM,
|
|
constraints: list[str] | None = None,
|
|
context: dict[str, Any] | None = None,
|
|
) -> str:
|
|
"""
|
|
Record a decision the agent made.
|
|
|
|
This is the PRIMARY method agents should call. It captures:
|
|
- What the agent was trying to do
|
|
- What options it considered
|
|
- What it chose and why
|
|
|
|
Args:
|
|
intent: What the agent was trying to accomplish
|
|
options: List of options considered. Each should have:
|
|
- id: Unique identifier
|
|
- description: What this option does
|
|
- action_type: "tool_call", "generate", "delegate", etc.
|
|
- action_params: Parameters for the action (optional)
|
|
- pros: Why this might be good (optional)
|
|
- cons: Why this might be bad (optional)
|
|
- confidence: How confident (0-1, optional)
|
|
chosen: ID of the chosen option
|
|
reasoning: Why the agent chose this option
|
|
node_id: Which node made this decision (uses current if not set)
|
|
decision_type: Type of decision
|
|
constraints: Active constraints that influenced the decision
|
|
context: Additional context available when deciding
|
|
|
|
Returns:
|
|
The decision ID (use this to record outcome later), or empty string if no run in progress
|
|
"""
|
|
if self._current_run is None:
|
|
# Gracefully handle case where run ended during exception handling
|
|
logger.warning(f"decide called but no run in progress: {intent}")
|
|
return ""
|
|
|
|
# Build Option objects
|
|
option_objects = []
|
|
for opt in options:
|
|
option_objects.append(Option(
|
|
id=opt["id"],
|
|
description=opt.get("description", ""),
|
|
action_type=opt.get("action_type", "unknown"),
|
|
action_params=opt.get("action_params", {}),
|
|
pros=opt.get("pros", []),
|
|
cons=opt.get("cons", []),
|
|
confidence=opt.get("confidence", 0.5),
|
|
))
|
|
|
|
# Create decision
|
|
decision_id = f"dec_{len(self._current_run.decisions)}"
|
|
decision = Decision(
|
|
id=decision_id,
|
|
node_id=node_id or self._current_node,
|
|
intent=intent,
|
|
decision_type=decision_type,
|
|
options=option_objects,
|
|
chosen_option_id=chosen,
|
|
reasoning=reasoning,
|
|
active_constraints=constraints or [],
|
|
input_context=context or {},
|
|
)
|
|
|
|
self._current_run.add_decision(decision)
|
|
return decision_id
|
|
|
|
def record_outcome(
|
|
self,
|
|
decision_id: str,
|
|
success: bool,
|
|
result: Any = None,
|
|
error: str | None = None,
|
|
summary: str = "",
|
|
state_changes: dict[str, Any] | None = None,
|
|
tokens_used: int = 0,
|
|
latency_ms: int = 0,
|
|
) -> None:
|
|
"""
|
|
Record the outcome of a decision.
|
|
|
|
Call this AFTER executing the action to record what happened.
|
|
|
|
Args:
|
|
decision_id: ID returned from decide()
|
|
success: Whether the action succeeded
|
|
result: The actual result/output
|
|
error: Error message if failed
|
|
summary: Human-readable summary of what happened
|
|
state_changes: What state changed as a result
|
|
tokens_used: LLM tokens consumed
|
|
latency_ms: Time taken in milliseconds
|
|
"""
|
|
if self._current_run is None:
|
|
# Gracefully handle case where run ended during exception handling
|
|
# This can happen in cascading error scenarios
|
|
logger.warning(f"record_outcome called but no run in progress (decision_id={decision_id})")
|
|
return
|
|
|
|
outcome = Outcome(
|
|
success=success,
|
|
result=result,
|
|
error=error,
|
|
summary=summary,
|
|
state_changes=state_changes or {},
|
|
tokens_used=tokens_used,
|
|
latency_ms=latency_ms,
|
|
)
|
|
|
|
self._current_run.record_outcome(decision_id, outcome)
|
|
|
|
# === PROBLEM RECORDING ===
|
|
|
|
def report_problem(
|
|
self,
|
|
severity: str,
|
|
description: str,
|
|
decision_id: str | None = None,
|
|
root_cause: str | None = None,
|
|
suggested_fix: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Report a problem that occurred.
|
|
|
|
Agents can self-report issues they notice. This helps Builder
|
|
understand what's going wrong.
|
|
|
|
Args:
|
|
severity: "critical", "warning", or "minor"
|
|
description: What went wrong
|
|
decision_id: Which decision caused this (if known)
|
|
root_cause: Why it went wrong (if known)
|
|
suggested_fix: What might fix it (if known)
|
|
|
|
Returns:
|
|
The problem ID, or empty string if no run in progress
|
|
"""
|
|
if self._current_run is None:
|
|
# Gracefully handle case where run ended during exception handling
|
|
# Log the problem since we can't store it, then return empty ID
|
|
logger.warning(f"report_problem called but no run in progress: [{severity}] {description}")
|
|
return ""
|
|
|
|
return self._current_run.add_problem(
|
|
severity=severity,
|
|
description=description,
|
|
decision_id=decision_id,
|
|
root_cause=root_cause,
|
|
suggested_fix=suggested_fix,
|
|
)
|
|
|
|
# === CONVENIENCE METHODS ===
|
|
|
|
def decide_and_execute(
|
|
self,
|
|
intent: str,
|
|
options: list[dict[str, Any]],
|
|
chosen: str,
|
|
reasoning: str,
|
|
executor: callable,
|
|
**kwargs,
|
|
) -> tuple[str, Any]:
|
|
"""
|
|
Record a decision and immediately execute it.
|
|
|
|
This is a convenience method that combines decide() and record_outcome().
|
|
|
|
Args:
|
|
intent: What the agent is trying to do
|
|
options: Options considered
|
|
chosen: ID of chosen option
|
|
reasoning: Why this option
|
|
executor: Function to call to execute the action
|
|
**kwargs: Additional args for decide()
|
|
|
|
Returns:
|
|
Tuple of (decision_id, result)
|
|
"""
|
|
import time
|
|
|
|
decision_id = self.decide(
|
|
intent=intent,
|
|
options=options,
|
|
chosen=chosen,
|
|
reasoning=reasoning,
|
|
**kwargs,
|
|
)
|
|
|
|
# Execute and measure
|
|
start = time.time()
|
|
try:
|
|
result = executor()
|
|
latency_ms = int((time.time() - start) * 1000)
|
|
|
|
self.record_outcome(
|
|
decision_id=decision_id,
|
|
success=True,
|
|
result=result,
|
|
latency_ms=latency_ms,
|
|
)
|
|
return decision_id, result
|
|
|
|
except Exception as e:
|
|
latency_ms = int((time.time() - start) * 1000)
|
|
|
|
self.record_outcome(
|
|
decision_id=decision_id,
|
|
success=False,
|
|
error=str(e),
|
|
latency_ms=latency_ms,
|
|
)
|
|
raise
|
|
|
|
def quick_decision(
|
|
self,
|
|
intent: str,
|
|
action: str,
|
|
reasoning: str,
|
|
node_id: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Record a simple decision with a single action (no alternatives).
|
|
|
|
Use this for straightforward decisions where there's really only
|
|
one sensible option.
|
|
|
|
Args:
|
|
intent: What the agent is trying to do
|
|
action: What it's doing
|
|
reasoning: Why
|
|
|
|
Returns:
|
|
The decision ID
|
|
"""
|
|
return self.decide(
|
|
intent=intent,
|
|
options=[{
|
|
"id": "action",
|
|
"description": action,
|
|
"action_type": "execute",
|
|
}],
|
|
chosen="action",
|
|
reasoning=reasoning,
|
|
node_id=node_id,
|
|
)
|