Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

֎ Multi-Agent Scheduling Assistant

A LangGraph state-machine that orchestrates a Triage Agent and a Booking Specialist to turn free-text chat into validated, persisted calendar bookings — with real negotiation on conflicts, not silent failure.

Live demo: <ADD_YOUR_DEPLOYED_URL_HERE>


Why it's built this way

Most "agent" scheduling demos wire everything through a single LLM tool-calling loop and hope the model remembers to validate dates. That's fragile: an LLM will happily call reserve_slot(date="tomorrow") and let a downstream system choke on it. This implementation treats date/time normalization and routing as deterministic code, and only asks the LLM to do what LLMs are actually good at — free-text intent classification and conversational replies. The result is a system that behaves identically on every run given the same input, which is what you want from something that touches a calendar and sends confirmations.

Architecture

                     ┌────────────┐
   user message ───► │   Triage   │
                     │   Agent    │
                     └─────┬──────┘
                intent=general │ intent=booking
                           │           │
                           ▼           ▼
                  ┌────────────┐  ┌──────────────────┐
                  │  General   │  │ Booking Specialist │
                  │   Reply    │  │  (extract_slot)     │
                  └─────┬──────┘  └─────────┬──────────┘
                        │               missing fields?
                        ▼                   │        │
                       END              yes │        │ no
                                             ▼        ▼
                                    ┌──────────┐  ┌────────────────────┐
                                    │ ask_missing│  │ check_availability │
                                    └─────┬────┘  └─────────┬───────────┘
                                          │            free │ taken
                                          ▼                 ▼        ▼
                                         END          ┌──────────┐ ┌────────────┐
                                                       │  reserve │ │ negotiate  │
                                                       └────┬─────┘ └─────┬──────┘
                                                    success │             │
                                                             ▼            ▼
                                                        ┌─────────┐     END
                                                        │ notify  │
                                                        └────┬────┘
                                                             ▼
                                                            END

This is implemented as a langgraph.graph.StateGraph in agent/graph.py. Every path that ends in END is a real conversational turn boundary — the graph pauses, hands control back to the user, and the next message resumes the same thread from its checkpoint (see Persistence below), so a partially-collected booking or an open negotiation survives across messages and browser refreshes.

Agents

Agent Responsibility File
Triage Agent Classifies each incoming message as general vs booking intent. Once a booking is mid-collection or mid-negotiation, it keeps routing to the Booking Specialist so a bare "3pm" reply isn't misclassified as small talk. agent/graph.py::triage_node
Booking Specialist Normalizes relative dates/times, tracks partially-collected slot fields (date, time, email, name) in graph state, drives the three tools, and negotiates alternatives on conflict. agent/graph.py::extract_slot_node, check_availability_node, reserve_node, notify_node, negotiate_node

Why explicit routing instead of LLM tool-calling for the Booking Specialist?

Both are valid; this repo picks explicit state-machine routing (agent/tools.py tools are still LangChain @tool-decorated, so you can swap in a ToolNode + model-driven loop with minimal changes if you prefer that style — the tools don't care who calls them). Explicit routing was chosen because it makes date validation, negotiation retry limits, and race-condition handling on reserve_slot fully deterministic and easy to unit test, which matters more here than flexibility of phrasing.

Input normalization

utils/date_utils.py resolves natural language into strict values before any tool executes:

  • "tomorrow", "today", "day after tomorrow" → relative offset from the server's current date
  • "next Monday", "Friday" → resolved weekday, correctly skipping the current day for "next X" phrasing
  • "July 15", "15/07/2026", "2026-07-15" → parsed and validated as real calendar dates (invalid dates like Feb 30 are rejected, not silently accepted)
  • "3pm", "15:00", "noon", "morning" → normalized 24h HH:MM
  • A resolved date that lands in the past is discarded, forcing the Booking Specialist to re-ask rather than book yesterday.

Negotiation & error handling

  • If the requested slot is taken, negotiate_node looks up real open slots for that date and offers up to 5 concrete alternatives — never a bare "sorry, try again."
  • If a date is fully booked, it asks for a different date instead of looping on an empty slot list.
  • A hard retry cap (MAX_NEGOTIATION_RETRIES = 3) prevents infinite negotiation loops; after that it explicitly asks the user to restart with a new date.
  • Race condition handling: reserve_slot re-validates availability under a lock at write time (not just at the earlier check_availability read), because two concurrent users can otherwise both "see" the same open slot. If the write loses the race, the graph re-checks availability and routes back into negotiate_node instead of raising.
  • send_booking_notification never raises on webhook failure — a confirmation-delivery hiccup is logged but does not unwind an already confirmed reservation.

Tools (mocked but functional)

All three live in agent/tools.py as LangChain @tool functions backed by db/database.py:

  • check_availability(date) — reads from a deterministically-seeded in-memory mock calendar (so the same date always has the same conflicts across runs, minus whatever's actually been booked in SQLite) merged with real bookings.
  • reserve_slot(date, time, email, name, thread_id) — inserts into a SQLite bookings table with a UNIQUE(date, time) constraint as a second line of defense against double-booking, on top of the lock-protected availability re-check.
  • send_booking_notification(email, details) — POSTs a confirmation payload to a configurable webhook (NOTIFICATION_WEBHOOK_URL, e.g. a Webhook.site or Pipedream RequestBin URL) to simulate an email/WhatsApp confirmation, and logs every attempt (success or failure) to a notifications table for audit.

State persistence

Conversation state is checkpointed per thread_id using LangGraph's SqliteSaver (agent/graph.py::build_graph), storing the full graph state (messages, in-progress slot fields, booking status, retry counts) to data/checkpoints.sqlite. Confirmed bookings are additionally written to a separate data/bookings.db for durable business records independent of conversational state.

In the Streamlit UI, the thread ID is carried in the URL (?thread=<id>), so refreshing the page resumes the exact same in-progress conversation — partial bookings, open negotiations, everything — rather than starting over. Use "Start a new conversation" in the sidebar to get a fresh thread.

LLM provider strategy

utils/llm.py tries providers in order — Groq → Gemini → OpenRouter — and falls back to a deterministic keyword/regex classifier if none are configured or all requests fail. This means the app is fully testable and gradeable with zero API keys set, while still using a real LLM for intent classification and conversational replies when keys are available. This mirrors the multi-provider fallback pattern used elsewhere in my projects (Groq → Gemini → OpenRouter) for resilience against rate limits/outages on any single provider.

Project structure

scheduling-assistant/
├── agent/
│   ├── graph.py          # StateGraph assembly, nodes, routing
│   ├── state.py          # AgentState / BookingSlot TypedDicts
│   └── tools.py           # check_availability, reserve_slot, send_booking_notification
├── db/
│   └── database.py       # SQLite bookings/notifications + mock availability
├── utils/
│   ├── date_utils.py      # relative date/time/email resolution
│   └── llm.py             # multi-provider LLM wrapper + fallback
├── app.py                 # Streamlit chat UI, thread persistence via URL
├── requirements.txt
├── render.yaml            # Render free-tier deploy config
├── Procfile                # fallback start command
├── runtime.txt
└── .env.example

Setup

git clone <this-repo-url>
cd scheduling-assistant
python -m venv .venv && source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env      # fill in whichever keys you have — all optional
streamlit run app.py

Open the URL Streamlit prints (default http://localhost:8501).

Environment variables

See .env.example for the full list. Nothing is required to run the app — every value degrades gracefully:

Variable Required? Effect if unset
GROQ_API_KEY / GEMINI_API_KEY / OPENROUTER_API_KEY No Falls back to rule-based intent classification and templated replies
NOTIFICATION_WEBHOOK_URL No Notification is logged locally instead of POSTed externally
SQLITE_DB_PATH, CHECKPOINT_DB_PATH No Defaults to data/bookings.db, data/checkpoints.sqlite

No API keys are committed to this repository. .env is git-ignored; only .env.example (with empty values) is tracked.

Testing it

Try this sequence in the chat:

> Hi, what can you do?
> I want to book an appointment
> tomorrow at 10am
> my email is you@example.com

To see negotiation in action, book a slot, then immediately try to book the same date/time again in a different thread (new tab / "Start a new conversation") — the seeded mock calendar plus the just-created real booking guarantees a conflict on the busier half-hour marks of any given day.

Deployment (free tier)

Option A — Streamlit Community Cloud (simplest)

  1. Push this repo to GitHub.
  2. Go to share.streamlit.io → "New app" → point at app.py on your repo/branch.
  3. Add your env vars under Settings → Secrets in TOML format, e.g.:
    GROQ_API_KEY = "..."
    NOTIFICATION_WEBHOOK_URL = "https://webhook.site/your-id"
  4. Deploy. Note: Streamlit Cloud's filesystem is ephemeral on redeploy, so data/*.sqlite* resets on redeploys (not on normal usage/sleep-wake).

Option B — Render

  1. Push to GitHub, then in Render: New → Blueprint, point at this repo — render.yaml is already configured (free web service + persistent disk mounted at data/ so SQLite files survive restarts).
  2. Set the same env vars as secrets in the Render dashboard.
  3. Render builds with pip install -r requirements.txt and starts with streamlit run app.py --server.port $PORT --server.address 0.0.0.0.

Option C — Hugging Face Spaces

  1. Create a new Space → SDK: Streamlit.
  2. Push this repo's contents to the Space's git remote.
  3. Add secrets under Settings → Repository secrets.
  4. HF Spaces persists /data under a persistent Space disk on paid tiers only; on the free tier, point SQLITE_DB_PATH/CHECKPOINT_DB_PATH at the default relative path — it will reset on Space restarts, same caveat as Streamlit Cloud's free tier.

Known limitations (by design, for a mock assessment build)

  • The mock calendar seeds "taken" slots deterministically from a hash of the date string — it's not a real calendar backend, intentionally, per the assignment spec ("mocked but functional").
  • Single business-hours calendar (09:00–18:00, 30-minute slots by default, configurable via .env) rather than multi-calendar/timezone support.
  • The webhook notification is fire-and-forget with a logged outcome, not a retried delivery queue — acceptable for a "mock webhook trigger" per spec.

About

Multi-agent scheduling assistant built with LangGraph — a Triage Agent + Booking Specialist that normalizes relative dates, negotiates on conflicts, and persists conversation state across sessions.

Topics

Resources

Stars

Watchers

Forks

Contributors

Languages