feat: meeting scheduler agent

This commit is contained in:
bryan
2026-03-03 20:01:58 -08:00
parent 0ce87b5155
commit 9ce753055c
8 changed files with 579 additions and 0 deletions
@@ -0,0 +1,34 @@
"""Meeting Scheduler — Find available times on your calendar and book meetings."""
from .agent import (
MeetingScheduler,
default_agent,
goal,
nodes,
edges,
entry_node,
entry_points,
pause_nodes,
terminal_nodes,
conversation_mode,
identity_prompt,
loop_config,
)
from .config import default_config, metadata
__all__ = [
"MeetingScheduler",
"default_agent",
"goal",
"nodes",
"edges",
"entry_node",
"entry_points",
"pause_nodes",
"terminal_nodes",
"conversation_mode",
"identity_prompt",
"loop_config",
"default_config",
"metadata",
]
@@ -0,0 +1,90 @@
"""CLI entry point for Meeting Scheduler."""
import asyncio, json, logging, sys
import click
from .agent import default_agent, MeetingScheduler
def setup_logging(verbose=False, debug=False):
if debug: level, fmt = logging.DEBUG, "%(asctime)s %(name)s: %(message)s"
elif verbose: level, fmt = logging.INFO, "%(message)s"
else: level, fmt = logging.WARNING, "%(levelname)s: %(message)s"
logging.basicConfig(level=level, format=fmt, stream=sys.stderr)
@click.group()
@click.version_option(version="1.0.0")
def cli():
"""Meeting Scheduler — Find available times on your calendar and book meetings."""
pass
@cli.command()
@click.option("--attendee", "-a", required=True, help="Attendee email address")
@click.option("--duration", "-d", type=int, required=True, help="Meeting duration in minutes")
@click.option("--title", "-t", required=True, help="Meeting title")
@click.option("--verbose", "-v", is_flag=True)
def run(attendee, duration, title, verbose):
"""Execute the scheduler."""
setup_logging(verbose=verbose)
result = asyncio.run(default_agent.run({
"attendee_email": attendee,
"meeting_duration_minutes": str(duration),
"meeting_title": title,
}))
click.echo(json.dumps({"success": result.success, "output": result.output}, indent=2, default=str))
sys.exit(0 if result.success else 1)
@cli.command()
def tui():
"""Launch TUI dashboard."""
from pathlib import Path
from framework.tui.app import AdenTUI
from framework.llm import LiteLLMProvider
from framework.runner.tool_registry import ToolRegistry
from framework.runtime.agent_runtime import create_agent_runtime
from framework.runtime.execution_stream import EntryPointSpec
async def run_tui():
agent = MeetingScheduler()
agent._tool_registry = ToolRegistry()
storage = Path.home() / ".hive" / "agents" / "meeting_scheduler"
storage.mkdir(parents=True, exist_ok=True)
mcp_cfg = Path(__file__).parent / "mcp_servers.json"
if mcp_cfg.exists(): agent._tool_registry.load_mcp_config(mcp_cfg)
llm = LiteLLMProvider(model=agent.config.model, api_key=agent.config.api_key, api_base=agent.config.api_base)
runtime = create_agent_runtime(
graph=agent._build_graph(), goal=agent.goal, storage_path=storage,
entry_points=[EntryPointSpec(id="start", name="Start", entry_node="intake", trigger_type="manual", isolation_level="isolated")],
llm=llm, tools=list(agent._tool_registry.get_tools().values()), tool_executor=agent._tool_registry.get_executor())
await runtime.start()
try:
app = AdenTUI(runtime)
await app.run_async()
finally:
await runtime.stop()
asyncio.run(run_tui())
@cli.command()
def info():
"""Show agent info."""
data = default_agent.info()
click.echo(f"Agent: {data['name']}\nVersion: {data['version']}\nDescription: {data['description']}")
click.echo(f"Nodes: {', '.join(data['nodes'])}\nClient-facing: {', '.join(data['client_facing_nodes'])}")
@cli.command()
def validate():
"""Validate agent structure."""
v = default_agent.validate()
if v["valid"]: click.echo("Agent is valid")
else:
click.echo("Errors:")
for e in v["errors"]: click.echo(f" {e}")
sys.exit(0 if v["valid"] else 1)
if __name__ == "__main__":
cli()
@@ -0,0 +1,164 @@
"""Agent graph construction for Meeting Scheduler."""
from pathlib import Path
from framework.graph import EdgeSpec, EdgeCondition, Goal, SuccessCriterion, Constraint
from framework.graph.edge import GraphSpec
from framework.graph.executor import ExecutionResult
from framework.graph.checkpoint_config import CheckpointConfig
from framework.llm import LiteLLMProvider
from framework.runner.tool_registry import ToolRegistry
from framework.runtime.agent_runtime import AgentRuntime, create_agent_runtime
from framework.runtime.execution_stream import EntryPointSpec
from .config import default_config, metadata
from .nodes import intake_node, schedule_node, confirm_node
# Goal definition
goal = Goal(
id="meeting-scheduler-goal",
name="Schedule Meetings",
description="Check calendar availability, find optimal meeting times, record meetings, and send reminders.",
success_criteria=[
SuccessCriterion(id="sc-1", description="Meeting time found within requested duration", metric="calendar_availability", target="success", weight=0.35),
SuccessCriterion(id="sc-2", description="Meeting recorded in spreadsheet accurately", metric="data_persistence", target="recorded", weight=0.30),
SuccessCriterion(id="sc-3", description="Attendee email reminder sent", metric="communication", target="sent", weight=0.25),
SuccessCriterion(id="sc-4", description="User confirms meeting details", metric="user_acknowledgment", target="confirmed", weight=0.10),
],
constraints=[
Constraint(id="c-1", description="Must use Google Calendar API for availability check", constraint_type="hard", category="functional"),
Constraint(id="c-2", description="Meeting duration must match requested time", constraint_type="hard", category="accuracy"),
Constraint(id="c-3", description="Spreadsheet record must include date, time, attendee, title", constraint_type="hard", category="quality"),
],
)
# Node list
nodes = [intake_node, schedule_node, confirm_node]
# Edge definitions
edges = [
EdgeSpec(id="intake-to-schedule", source="intake", target="schedule",
condition=EdgeCondition.ON_SUCCESS, priority=1),
EdgeSpec(id="schedule-to-confirm", source="schedule", target="confirm",
condition=EdgeCondition.ON_SUCCESS, priority=1),
# Loop back for another booking
EdgeSpec(id="confirm-to-intake", source="confirm", target="intake",
condition=EdgeCondition.CONDITIONAL,
condition_expr="str(next_action).lower() == 'another'", priority=1),
]
# Graph configuration
entry_node = "intake"
entry_points = {"start": "intake"}
pause_nodes = []
terminal_nodes = [] # Forever-alive
# Module-level vars read by AgentRunner.load()
conversation_mode = "continuous"
identity_prompt = "You are a helpful meeting scheduler assistant that manages calendar availability and sends confirmations."
loop_config = {"max_iterations": 100, "max_tool_calls_per_turn": 20, "max_history_tokens": 32000}
class MeetingScheduler:
def __init__(self, config=None):
self.config = config or default_config
self.goal = goal
self.nodes = nodes
self.edges = edges
self.entry_node = entry_node
self.entry_points = entry_points
self.pause_nodes = pause_nodes
self.terminal_nodes = terminal_nodes
self._graph = None
self._agent_runtime = None
self._tool_registry = None
self._storage_path = None
def _build_graph(self):
return GraphSpec(
id="meeting-scheduler-graph",
goal_id=self.goal.id,
version="1.0.0",
entry_node=self.entry_node,
entry_points=self.entry_points,
terminal_nodes=self.terminal_nodes,
pause_nodes=self.pause_nodes,
nodes=self.nodes,
edges=self.edges,
default_model=self.config.model,
max_tokens=self.config.max_tokens,
loop_config=loop_config,
conversation_mode=conversation_mode,
identity_prompt=identity_prompt,
)
def _setup(self):
self._storage_path = Path.home() / ".hive" / "agents" / "meeting_scheduler"
self._storage_path.mkdir(parents=True, exist_ok=True)
self._tool_registry = ToolRegistry()
mcp_config = Path(__file__).parent / "mcp_servers.json"
if mcp_config.exists():
self._tool_registry.load_mcp_config(mcp_config)
llm = LiteLLMProvider(model=self.config.model, api_key=self.config.api_key, api_base=self.config.api_base)
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(
graph=self._graph, goal=self.goal, storage_path=self._storage_path,
entry_points=[EntryPointSpec(id="default", name="Default", entry_node=self.entry_node,
trigger_type="manual", isolation_level="shared")],
llm=llm, tools=tools, tool_executor=tool_executor,
checkpoint_config=CheckpointConfig(enabled=True, checkpoint_on_node_complete=True,
checkpoint_max_age_days=7, async_checkpoint=True),
)
async def start(self):
if self._agent_runtime is None:
self._setup()
if not self._agent_runtime.is_running:
await self._agent_runtime.start()
async def stop(self):
if self._agent_runtime and self._agent_runtime.is_running:
await self._agent_runtime.stop()
self._agent_runtime = None
async def trigger_and_wait(self, entry_point="default", input_data=None, timeout=None, session_state=None):
if self._agent_runtime is None:
raise RuntimeError("Agent not started. Call start() first.")
return await self._agent_runtime.trigger_and_wait(
entry_point_id=entry_point, input_data=input_data or {}, session_state=session_state)
async def run(self, context, session_state=None):
await self.start()
try:
result = await self.trigger_and_wait("default", context, session_state=session_state)
return result or ExecutionResult(success=False, error="Execution timeout")
finally:
await self.stop()
def info(self):
return {
"name": metadata.name, "version": metadata.version, "description": metadata.description,
"goal": {"name": self.goal.name, "description": self.goal.description},
"nodes": [n.id for n in self.nodes], "edges": [e.id for e in self.edges],
"entry_node": self.entry_node, "entry_points": self.entry_points,
"terminal_nodes": self.terminal_nodes,
"client_facing_nodes": [n.id for n in self.nodes if n.client_facing],
}
def validate(self):
errors, warnings = [], []
node_ids = {n.id for n in self.nodes}
for e in self.edges:
if e.source not in node_ids: errors.append(f"Edge {e.id}: source '{e.source}' not found")
if e.target not in node_ids: errors.append(f"Edge {e.id}: target '{e.target}' not found")
if self.entry_node not in node_ids: errors.append(f"Entry node '{self.entry_node}' not found")
for t in self.terminal_nodes:
if t not in node_ids: errors.append(f"Terminal node '{t}' not found")
for ep_id, nid in self.entry_points.items():
if nid not in node_ids: errors.append(f"Entry point '{ep_id}' references unknown node '{nid}'")
return {"valid": len(errors) == 0, "errors": errors, "warnings": warnings}
default_agent = MeetingScheduler()
@@ -0,0 +1,28 @@
"""Runtime configuration."""
from dataclasses import dataclass
from framework.config import RuntimeConfig
default_config = RuntimeConfig()
@dataclass
class AgentMetadata:
name: str = "Meeting Scheduler"
version: str = "1.0.0"
description: str = (
"Schedule meetings by checking Google Calendar availability, booking "
"optimal time slots, recording details in Google Sheets, and sending "
"email confirmations with Google Meet links to attendees."
)
intro_message: str = (
"Hi! I'm your meeting scheduler. Tell me who you'd like to meet with, "
"how long the meeting should be, and what it's about — I'll check "
"calendar availability, book a time slot, log it to your spreadsheet, "
"and send a confirmation email with a Google Meet link. "
"Who would you like to schedule a meeting with?"
)
metadata = AgentMetadata()
@@ -0,0 +1,9 @@
{
"hive-tools": {
"transport": "stdio",
"command": "uv",
"args": ["run", "python", "mcp_server.py", "--stdio"],
"cwd": "../../../tools",
"description": "Hive tools MCP server"
}
}
@@ -0,0 +1,130 @@
"""Node definitions for Meeting Scheduler."""
from framework.graph import NodeSpec
# Node 1: Intake (client-facing)
intake_node = NodeSpec(
id="intake",
name="Intake",
description="Gather meeting details from the user",
node_type="event_loop",
client_facing=True,
max_node_visits=0,
input_keys=["attendee_email", "meeting_duration_minutes"],
output_keys=["attendee_email", "meeting_duration_minutes", "meeting_title"],
nullable_output_keys=["attendee_email", "meeting_duration_minutes", "meeting_title"],
success_criteria="User has provided attendee email, meeting duration, and title.",
system_prompt="""\
You are a meeting scheduler assistant.
**STEP 1 Use ask_user to collect meeting details:**
1. Call ask_user to ask for: attendee email, meeting duration (minutes), and meeting title
2. Wait for the user's response before proceeding
**STEP 2 After user provides all details, call set_output:**
- set_output("attendee_email", "user's email address")
- set_output("meeting_duration_minutes", meeting duration as string)
- set_output("meeting_title", "title of the meeting")
""",
tools=[],
)
# Node 2: Schedule (autonomous)
schedule_node = NodeSpec(
id="schedule",
name="Schedule",
description="Find available time on calendar, book meeting with Google Meet, and log to Google Sheet",
node_type="event_loop",
max_node_visits=0,
input_keys=["attendee_email", "meeting_duration_minutes", "meeting_title"],
output_keys=["meeting_time", "booking_confirmed", "spreadsheet_recorded", "email_sent", "meet_link"],
nullable_output_keys=[],
success_criteria="Meeting time found, Google Meet created, Google Sheet 'Meeting Scheduler' updated with date/time/attendee/title/meet_link, and confirmation email sent.",
system_prompt="""\
You are a meeting booking agent that creates Google Calendar events with Google Meet and logs to Google Sheets.
## STEP 1 — Calendar Operations (tool calls in this turn):
1. **Find availability and verify conflicts:**
- Use calendar_check_availability to find potential time slots.
- **CRITICAL:** Always search a broad window (at least 8 hours) for the target day to see the full context of the user's schedule.
- **SECONDARY CHECK:** Before finalizing a slot, use calendar_list_events for that specific day. This ensures you catch "soft" conflicts or events not marked as 'busy' that might still be important.
2. **Create the event WITH GOOGLE MEET (AUTOMATIC):**
- Use calendar_create_event with these parameters:
- summary: the meeting title
- start_time: the start datetime in ISO format (e.g., "2024-01-15T09:00:00")
- end_time: the end datetime in ISO format
- attendees: list with the attendee email address (e.g., ["user@example.com"])
- timezone: user's timezone (e.g., "America/Los_Angeles")
- IMPORTANT: The tool automatically generates a Google Meet link when attendees are provided.
You do NOT need to pass conferenceData - it is handled automatically.
- The response will include conferenceData.entryPoints with the Google Meet link
- Extract the meet_link from conferenceData.entryPoints[0].uri in the response
3. **Log to Google Sheets:**
- First, use google_sheets_get_spreadsheet with spreadsheet_id="Meeting Scheduler" to check if it exists
- If it doesn't exist, use google_sheets_create_spreadsheet with title="Meeting Scheduler"
- Then use google_sheets_append_values to add a row with:
- Date, Time, Attendee Email, Meeting Title, Google Meet Link
- The spreadsheet_id should be "Meeting Scheduler" (by name) or the ID returned from create
4. **Send confirmation email:**
- Use send_email to send the attendee a confirmation with:
- to: attendee email address
- subject: "Meeting Confirmation: {meeting_title}"
- body: Include meeting title, date/time, and Google Meet link
## STEP 2 — set_output (SEPARATE turn, no other tool calls):
After all tools complete successfully, call set_output:
- set_output("meeting_time", "YYYY-MM-DD HH:MM")
- set_output("meet_link", "https://meet.google.com/xxx/yyy")
- set_output("booking_confirmed", "true")
- set_output("spreadsheet_recorded", "true")
- set_output("email_sent", "true")
## CRITICAL: Google Meet creation
Google Meet links are AUTOMATICALLY created by calendar_create_event when attendees are provided.
Simply pass the attendees list and the tool will generate the Meet link.
""",
tools=[
"calendar_check_availability",
"calendar_create_event",
"calendar_list_events",
"google_sheets_create_spreadsheet",
"google_sheets_get_spreadsheet",
"google_sheets_append_values",
"send_email",
],
)
# Node 3: Confirm (client-facing)
confirm_node = NodeSpec(
id="confirm",
name="Confirm",
description="Present booking confirmation to user with Google Meet link",
node_type="event_loop",
client_facing=True,
max_node_visits=0,
input_keys=["meeting_time", "booking_confirmed", "meet_link"],
output_keys=["next_action"],
nullable_output_keys=["next_action"],
success_criteria="User has acknowledged the booking and received the Google Meet link.",
system_prompt="""\
You are a confirmation assistant.
**STEP 1 Present confirmation and ask user:**
1. Show the meeting details (date, time, attendee, title)
2. Display the Google Meet link prominently
3. Confirm the booking is complete and logged to Google Sheets
4. Call ask_user to ask if they want to schedule another meeting or finish
**STEP 2 After user responds, call set_output:**
- set_output("next_action", "another") if booking another meeting
- set_output("next_action", "done") if finished
""",
tools=[],
)
__all__ = ["intake_node", "schedule_node", "confirm_node"]
@@ -0,0 +1,32 @@
"""Test fixtures."""
import sys
from pathlib import Path
import pytest
_repo_root = Path(__file__).resolve().parents[4]
for _p in ["examples/templates", "core"]:
_path = str(_repo_root / _p)
if _path not in sys.path:
sys.path.insert(0, _path)
AGENT_PATH = str(Path(__file__).resolve().parents[1])
@pytest.fixture(scope="session")
def agent_module():
"""Import the agent package for structural validation."""
import importlib
return importlib.import_module(Path(AGENT_PATH).name)
@pytest.fixture(scope="session")
def runner_loaded():
"""Load the agent through AgentRunner (structural only, no LLM needed)."""
from framework.runner.runner import AgentRunner
from framework.credentials.models import CredentialError
try:
return AgentRunner.load(AGENT_PATH)
except CredentialError:
pytest.skip("Google OAuth credentials not configured")
@@ -0,0 +1,92 @@
"""Structural tests for Meeting Scheduler."""
import pytest
from meeting_scheduler import (
default_agent, goal, nodes, edges, entry_node, entry_points,
terminal_nodes, conversation_mode, identity_prompt, loop_config
)
class TestGoalDefinition:
def test_goal_exists(self):
assert goal is not None
assert goal.id == "meeting-scheduler-goal"
assert len(goal.success_criteria) == 4
assert len(goal.constraints) == 3
def test_success_criteria_weights_sum_to_one(self):
total = sum(sc.weight for sc in goal.success_criteria)
assert abs(total - 1.0) < 0.01
class TestNodeStructure:
def test_three_nodes(self):
assert len(nodes) == 3
assert nodes[0].id == "intake"
assert nodes[1].id == "schedule"
assert nodes[2].id == "confirm"
def test_intake_is_client_facing(self):
assert nodes[0].client_facing is True
def test_schedule_has_required_tools(self):
required = {"calendar_check_availability", "calendar_create_event", "google_sheets_append_values", "send_email"}
actual = set(nodes[1].tools)
assert required.issubset(actual)
def test_confirm_is_client_facing(self):
assert nodes[2].client_facing is True
class TestEdgeStructure:
def test_three_edges(self):
assert len(edges) == 3
def test_linear_path(self):
assert edges[0].source == "intake"
assert edges[0].target == "schedule"
assert edges[1].source == "schedule"
assert edges[1].target == "confirm"
def test_loop_back(self):
assert edges[2].source == "confirm"
assert edges[2].target == "intake"
class TestGraphConfiguration:
def test_entry_node(self):
assert entry_node == "intake"
def test_entry_points(self):
assert entry_points == {"start": "intake"}
def test_forever_alive(self):
assert terminal_nodes == []
def test_conversation_mode(self):
assert conversation_mode == "continuous"
def test_loop_config_valid(self):
assert "max_iterations" in loop_config
assert "max_tool_calls_per_turn" in loop_config
assert "max_history_tokens" in loop_config
class TestAgentClass:
def test_default_agent_created(self):
assert default_agent is not None
def test_validate_passes(self):
result = default_agent.validate()
assert result["valid"] is True
assert len(result["errors"]) == 0
def test_agent_info(self):
info = default_agent.info()
assert info["name"] == "Meeting Scheduler"
assert "schedule" in [n for n in info["nodes"]]
class TestRunnerLoad:
def test_agent_runner_load_succeeds(self, runner_loaded):
assert runner_loaded is not None