From 626bc9acffd67a57a2bf4ebf8d3b06efa7d41540 Mon Sep 17 00:00:00 2001 From: vakrahul Date: Sun, 1 Mar 2026 22:32:30 +0530 Subject: [PATCH 1/5] feat(templates): add Automated HR Screener template --- examples/templates/hr_screener/README.md | 189 +++++++++ examples/templates/hr_screener/__init__.py | 24 ++ examples/templates/hr_screener/__main__.py | 373 ++++++++++++++++++ examples/templates/hr_screener/agent.json | 305 ++++++++++++++ examples/templates/hr_screener/agent.py | 325 +++++++++++++++ examples/templates/hr_screener/config.py | 26 ++ .../templates/hr_screener/mcp_servers.json | 9 + .../templates/hr_screener/nodes/__init__.py | 190 +++++++++ 8 files changed, 1441 insertions(+) create mode 100644 examples/templates/hr_screener/README.md create mode 100644 examples/templates/hr_screener/__init__.py create mode 100644 examples/templates/hr_screener/__main__.py create mode 100644 examples/templates/hr_screener/agent.json create mode 100644 examples/templates/hr_screener/agent.py create mode 100644 examples/templates/hr_screener/config.py create mode 100644 examples/templates/hr_screener/mcp_servers.json create mode 100644 examples/templates/hr_screener/nodes/__init__.py diff --git a/examples/templates/hr_screener/README.md b/examples/templates/hr_screener/README.md new file mode 100644 index 0000000000..b0f6f1b356 --- /dev/null +++ b/examples/templates/hr_screener/README.md @@ -0,0 +1,189 @@ +# Automated HR Screener + +**Version**: 1.0.0 +**Type**: Multi-node agent template + +## Overview + +Screen PDF resumes against a job description, score and rank candidates 1–100, produce a plain text report, and optionally send response emails via [Resend](https://resend.com). All resume processing runs locally — applicant PII never leaves your machine unless you opt in to email notifications. + +## Quick Start + +### 1. Configure your LLM provider + +Edit `~/.hive/configuration.json` with **any** supported provider: + +```json +{ + "llm": { + "provider": "", + "model": "" + } +} +``` + +### 2. Set your API key + +```bash +# Linux / macOS +export _API_KEY=your-key-here +export RESEND_API_KEY=your-key-here # only if you want email notifications + +# Windows CMD +set _API_KEY=your-key-here +set RESEND_API_KEY=your-key-here +``` + +### 3. Run + +```bash +python -m examples.templates.hr_screener tui +``` + +The setup wizard will collect your job description, resume path, and email preference, then the pipeline runs automatically. + +--- + +## Supported LLM Providers + +This agent is built on [LiteLLM](https://docs.litellm.ai/) and works with **any provider** it supports. Below are tested configurations. + +> [!TIP] +> Models with 70B+ parameters produce the best results for tool calling reliability. + +### Cloud Providers + +| Provider | Config `provider` | Config `model` | Env Var | Free Tier | +|----------|-------------------|----------------|---------|-----------| +| **Anthropic** | `anthropic` | `claude-sonnet-4-20250514` | `ANTHROPIC_API_KEY` | No | +| **Google Gemini** | `gemini` | `gemini-2.5-flash` | `GEMINI_API_KEY` | Yes | +| **Groq** | `groq` | `llama-3.3-70b-versatile` | `GROQ_API_KEY` | Yes | +| **HuggingFace** | `huggingface` | `meta-llama/Llama-3.3-70B-Instruct` | `HUGGINGFACE_API_KEY` | Limited | +| **Cerebras** | `cerebras` | `llama3.1-8b` | `CEREBRAS_API_KEY` | Yes | +| **DeepSeek** | `deepseek` | `deepseek-chat` | `DEEPSEEK_API_KEY` | Yes | +| **OpenRouter** | `openrouter` | `meta-llama/llama-3-8b-instruct:free` | `OPENROUTER_API_KEY` | Yes | +| **Together AI** | `together_ai` | `meta-llama/Llama-3-8b-chat-hf` | `TOGETHERAI_API_KEY` | Limited | + +**Example — Anthropic Claude:** +```json +{ + "llm": { + "provider": "anthropic", + "model": "claude-sonnet-4-20250514" + } +} +``` +```bash +export ANTHROPIC_API_KEY=sk-ant-... +``` + +**Example — Groq (Free, Fast):** +```json +{ + "llm": { + "provider": "groq", + "model": "llama-3.3-70b-versatile" + } +} +``` +```bash +export GROQ_API_KEY=gsk_... +``` + +### Local LLMs (Ollama) — Recommended for Privacy + +Resumes contain heavy PII. Running local models ensures candidate data never leaves your machine. + +1. Install [Ollama](https://ollama.com/) +2. Pull a model: `ollama pull qwen2.5:14b` +3. Configure: +```json +{ + "llm": { + "provider": "ollama_chat", + "model": "qwen2.5:14b", + "api_base": "http://localhost:11434" + } +} +``` + +No API key needed for local models. + +--- + +## Architecture + +### Pipeline Flow + +``` +intake → scan-resumes → rank-candidates → generate-report → notify-candidates +``` + +### Nodes (5 total) + +| Node | Type | Client-Facing | Tools | Description | +|------|------|---------------|-------|-------------| +| **intake** | event_loop | No | — | Pass pre-collected inputs (job description, resume path, email pref) into the pipeline | +| **scan-resumes** | event_loop | No | `pdf_read`, `list_dir` | Read each PDF resume and extract text | +| **rank-candidates** | event_loop | No | — | Score candidates 0–100 against the job description | +| **generate-report** | event_loop | No | `save_data` | Write a plain text screening report | +| **notify-candidates** | event_loop | Yes | `send_email` | Send response emails (requires user approval) | + +### Scoring Criteria + +| Criterion | Weight | Description | +|-----------|--------|-------------| +| Skills Match | 40% | Technical skills vs. job requirements | +| Experience | 25% | Level and type of relevant experience | +| Education | 15% | Degrees, certifications | +| Communication | 10% | Resume clarity and professionalism | +| Bonus Factors | 10% | Projects, leadership, publications | + +--- + +## CLI Commands + +```bash +# Interactive TUI dashboard (recommended) +python -m examples.templates.hr_screener tui + +# Interactive CLI shell +python -m examples.templates.hr_screener shell + +# Headless mode (outputs JSON) +python -m examples.templates.hr_screener run + +# Show agent info +python -m examples.templates.hr_screener info + +# Validate graph structure +python -m examples.templates.hr_screener validate +``` + +--- + +## Required Tools + +These tools must be available via the MCP server configuration (`mcp_servers.json`): + +- `pdf_read` — Extract text from PDF files +- `list_dir` — List directory contents +- `save_data` — Save report to file +- `send_email` — Send emails via Resend API + +--- + +## Constraints + +| Constraint | Type | Category | +|------------|------|----------| +| Never fabricate resume content or scores | Hard | Quality | +| Never transmit raw resume content externally | Hard | Privacy | +| Never send emails without user approval | Hard | Safety | +| Apply scoring criteria consistently | Hard | Quality | + +--- + +## Version History + +- **1.0.0** (2026-02-21): Initial release — 5 nodes, 4 edges, multi-provider LLM support, privacy-focused design diff --git a/examples/templates/hr_screener/__init__.py b/examples/templates/hr_screener/__init__.py new file mode 100644 index 0000000000..c64d385532 --- /dev/null +++ b/examples/templates/hr_screener/__init__.py @@ -0,0 +1,24 @@ +""" +Automated HR Screener - Screen resumes, rank candidates, and send response emails. + +Reads PDF resumes locally, scores candidates against a job description, +produces a Top-5 HTML report, and optionally sends personalized emails. +Designed for local LLM usage to keep applicant PII private. +""" + +from .agent import HRScreenerAgent, default_agent, goal, nodes, edges +from .config import RuntimeConfig, AgentMetadata, default_config, metadata + +__version__ = "1.0.0" + +__all__ = [ + "HRScreenerAgent", + "default_agent", + "goal", + "nodes", + "edges", + "RuntimeConfig", + "AgentMetadata", + "default_config", + "metadata", +] diff --git a/examples/templates/hr_screener/__main__.py b/examples/templates/hr_screener/__main__.py new file mode 100644 index 0000000000..07bb0ffd82 --- /dev/null +++ b/examples/templates/hr_screener/__main__.py @@ -0,0 +1,373 @@ +""" +CLI entry point for Automated HR Screener. + +Uses AgentRuntime for multi-entrypoint support with HITL pause/resume. +""" + +import asyncio +import json +import logging +import sys +import click + +from .agent import default_agent, HRScreenerAgent + + +def setup_logging(verbose=False, debug=False): + """Configure logging for execution visibility.""" + 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) + logging.getLogger("framework").setLevel(level) + + +@click.group() +@click.version_option(version="1.0.0") +def cli(): + """Automated HR Screener - Screen resumes and rank candidates.""" + pass + + +@cli.command() +@click.option("--quiet", "-q", is_flag=True, help="Only output result JSON") +@click.option("--verbose", "-v", is_flag=True, help="Show execution details") +@click.option("--debug", is_flag=True, help="Show debug logging") +def run(quiet, verbose, debug): + """Execute the HR screener agent.""" + if not quiet: + setup_logging(verbose=verbose, debug=debug) + + context = {} + + result = asyncio.run(default_agent.run(context)) + + output_data = { + "success": result.success, + "steps_executed": result.steps_executed, + "output": result.output, + } + if result.error: + output_data["error"] = result.error + + click.echo(json.dumps(output_data, indent=2, default=str)) + sys.exit(0 if result.success else 1) + + +@cli.command() +@click.option("--verbose", "-v", is_flag=True, help="Show execution details") +@click.option("--debug", is_flag=True, help="Show debug logging") +def tui(verbose, debug): + """Launch the TUI dashboard for interactive HR screening.""" + setup_logging(verbose=verbose, debug=debug) + + import os + import sys + + try: + from dotenv import load_dotenv + + load_dotenv() + except ImportError: + pass + + try: + from rich.console import Console + from rich.panel import Panel + from rich.text import Text + + console = Console() + welcome_text = Text() + welcome_text.append("Welcome to the ", style="yellow") + welcome_text.append("HR Screener Agent", style="bold yellow") + welcome_text.append(" \n\n", style="yellow") + welcome_text.append( + "• Drag & drop PDF resumes to screen them automatically.\n", style="yellow" + ) + welcome_text.append( + "• Evaluates candidates against your specific Job Description.\n", + style="yellow", + ) + welcome_text.append( + "• Sends official formatted acceptance/rejection emails via Resend.\n\n", + style="yellow", + ) + welcome_text.append("Press ", style="yellow") + welcome_text.append("Ctrl+C", style="bold yellow") + welcome_text.append(" at any time to exit.", style="yellow") + + console.print( + Panel( + welcome_text, + title="[bold yellow]Automated HR Screener[/bold yellow]", + border_style="yellow", + expand=False, + ) + ) + print("\n") + except ImportError: + print("\n=== Welcome to the HR Screener Agent ===\n") + + # Clean up quotes if user used `set KEY="value"` in Windows CMD + for env_key in [ + "ANTHROPIC_API_KEY", + "GROQ_API_KEY", + "HUGGINGFACE_API_KEY", + "CEREBRAS_API_KEY", + "GEMINI_API_KEY", + "OPENROUTER_API_KEY", + "DEEPSEEK_API_KEY", + "SAMBANOVA_API_KEY", + "RESEND_API_KEY", + ]: + val = os.environ.get(env_key, "").strip("\"'") + if val: + os.environ[env_key] = val + + # Check Resend key for email notifications + resend_key = os.environ.get("RESEND_API_KEY", "").strip() + if not resend_key: + print( + "\n ℹ RESEND_API_KEY not set — email notifications will be unavailable." + ) + resend_input = input( + " Enter Resend API key (or press Enter to skip): " + ).strip() + if resend_input: + os.environ["RESEND_API_KEY"] = resend_input + print(" ✓ RESEND_API_KEY set successfully.") + + # --- Collect inputs upfront in Python --- + print("\n" + "=" * 60) + print(" 🐝 HR SCREENER SETUP") + print("=" * 60) + + # 1. Job Description + print("\n🐝 [1/3] Job Description") + print("Paste the full job description below.") + print("When finished, type --- on a new line and press Enter.") + jd_lines = [] + while True: + line = input() + if line.strip() == "---": + break + jd_lines.append(line) + job_description = "\n".join(jd_lines).strip() + + # 2. Resume PDF path + print("\n🐝 [2/3] Resume File Path") + while True: + resume_path = ( + input("Enter the full path to the resume PDF: ").strip().strip('"') + ) + if resume_path: + break + print(" Path cannot be empty.") + + # 3. Email notifications + print("\n🐝 [3/3] Email Notifications") + while True: + notify = ( + input("Send email notifications to candidates after screening? (yes/no): ") + .strip() + .lower() + ) + if notify in ("yes", "no", "y", "n"): + notify_candidates = "yes" if notify in ("yes", "y") else "no" + break + print(" Please type yes or no.") + + # Summary + print("\n" + "-" * 60) + print( + f" Job Description : {job_description[:60]}..." + if len(job_description) > 60 + else f" Job Description : {job_description}" + ) + print(f" Resume File : {resume_path}") + print(f" Email Notify : {notify_candidates}") + print("-" * 60) + input("Press Enter to start screening...") + + # Store collected inputs as environment variables for the intake node + os.environ["HR_JOB_DESCRIPTION"] = job_description + os.environ["HR_RESUME_FILES"] = resume_path + os.environ["HR_NOTIFY_CANDIDATES"] = notify_candidates + + try: + from framework.tui.app import AdenTUI + except ImportError: + click.echo( + "TUI requires the 'textual' package. Install with: pip install textual" + ) + sys.exit(1) + + from pathlib import Path + + from framework.llm import LiteLLMProvider + from framework.runner.tool_registry import ToolRegistry + from framework.runtime.agent_runtime import create_agent_runtime + from framework.runtime.event_bus import EventBus + from framework.runtime.execution_stream import EntryPointSpec + + async def run_with_tui(): + agent = HRScreenerAgent() + + agent._event_bus = EventBus() + agent._tool_registry = ToolRegistry() + + storage_path = Path.home() / ".hive" / "agents" / "hr_screener" + storage_path.mkdir(parents=True, exist_ok=True) + + mcp_config_path = Path(__file__).parent / "mcp_servers.json" + if mcp_config_path.exists(): + agent._tool_registry.load_mcp_config(mcp_config_path) + + llm = LiteLLMProvider( + model=agent.config.model, + api_key=agent.config.api_key, + api_base=agent.config.api_base, + ) + + tools = list(agent._tool_registry.get_tools().values()) + tool_executor = agent._tool_registry.get_executor() + graph = agent._build_graph() + + runtime = create_agent_runtime( + graph=graph, + goal=agent.goal, + storage_path=storage_path, + entry_points=[ + EntryPointSpec( + id="start", + name="Start HR Screening", + entry_node="intake", + trigger_type="manual", + isolation_level="isolated", + ), + ], + llm=llm, + tools=tools, + tool_executor=tool_executor, + ) + + await runtime.start() + + # Set welcome message for TUI chat pane + jd_short = ( + job_description[:60] + "..." + if len(job_description) > 60 + else job_description + ) + runtime.intro_message = ( + f"Welcome to the Automated HR Screener!\n\n" + f" Job Description : {jd_short}\n" + f" Resume File : {resume_path}\n" + f" Email Notify : {notify_candidates}\n\n" + f"Type 'yes' to start screening." + ) + + try: + app = AdenTUI(runtime) + await app.run_async() + finally: + await runtime.stop() + + asyncio.run(run_with_tui()) + + +@cli.command() +@click.option("--json", "output_json", is_flag=True) +def info(output_json): + """Show agent information.""" + info_data = default_agent.info() + if output_json: + click.echo(json.dumps(info_data, indent=2)) + else: + click.echo(f"Agent: {info_data['name']}") + click.echo(f"Version: {info_data['version']}") + click.echo(f"Description: {info_data['description']}") + click.echo(f"\nNodes: {', '.join(info_data['nodes'])}") + click.echo(f"Client-facing: {', '.join(info_data['client_facing_nodes'])}") + click.echo(f"Entry: {info_data['entry_node']}") + click.echo(f"Terminal: {', '.join(info_data['terminal_nodes'])}") + + +@cli.command() +def validate(): + """Validate agent structure.""" + validation = default_agent.validate() + if validation["valid"]: + click.echo("Agent is valid") + if validation["warnings"]: + for warning in validation["warnings"]: + click.echo(f" WARNING: {warning}") + else: + click.echo("Agent has errors:") + for error in validation["errors"]: + click.echo(f" ERROR: {error}") + sys.exit(0 if validation["valid"] else 1) + + +@cli.command() +@click.option("--verbose", "-v", is_flag=True) +def shell(verbose): + """Interactive HR screening session (CLI, no TUI).""" + asyncio.run(_interactive_shell(verbose)) + + +async def _interactive_shell(verbose=False): + """Async interactive shell.""" + setup_logging(verbose=verbose) + + click.echo("=== Automated HR Screener ===") + click.echo("Paste a job description and provide a resume folder to get started.") + click.echo( + "Commands: /sessions to see previous sessions, /pause to pause execution\n" + ) + + agent = HRScreenerAgent() + await agent.start() + + try: + while True: + try: + user_input = await asyncio.get_event_loop().run_in_executor( + None, input, "HR> " + ) + if user_input.lower() in ["quit", "exit", "q"]: + click.echo("Goodbye!") + break + + click.echo("\nProcessing resumes...\n") + + result = await agent.trigger_and_wait("start", {}) + + if result is None: + click.echo("\n[Execution timed out]\n") + continue + + if result.success: + output = result.output + if "report_file" in output: + click.echo(f"\nReport saved: {output['report_file']}\n") + else: + click.echo(f"\nFailed: {result.error}\n") + + except KeyboardInterrupt: + click.echo("\nGoodbye!") + break + except Exception as e: + click.echo(f"Error: {e}", err=True) + import traceback + + traceback.print_exc() + finally: + await agent.stop() + + +if __name__ == "__main__": + cli() diff --git a/examples/templates/hr_screener/agent.json b/examples/templates/hr_screener/agent.json new file mode 100644 index 0000000000..a283aaa5ba --- /dev/null +++ b/examples/templates/hr_screener/agent.json @@ -0,0 +1,305 @@ +{ + "agent": { + "id": "hr_screener", + "name": "Automated HR Screener", + "version": "1.0.0", + "description": "Screen PDF resumes against a job description, score and rank candidates, produce a Top-5 report, and optionally send response emails. Designed for local LLM usage to keep applicant PII private." + }, + "graph": { + "id": "hr-screener-graph", + "goal_id": "hr-screening", + "version": "1.0.0", + "entry_node": "intake", + "entry_points": { + "start": "intake" + }, + "pause_nodes": [], + "terminal_nodes": [ + "notify-candidates" + ], + "conversation_mode": "continuous", + "identity_prompt": "You are a rigorous and highly professional corporate human resources and engineering evaluation agent. You evaluate candidates based strictly on merit without fabrication, adhering to privacy constraints.", + "nodes": [ + { + "id": "intake", + "name": "Intake", + "description": "Pass pre-collected inputs (job description, resume path, email notification preference) into the pipeline.", + "node_type": "event_loop", + "input_keys": [], + "output_keys": [ + "job_description", + "resume_files", + "notify_candidates" + ], + "nullable_output_keys": [], + "input_schema": {}, + "output_schema": {}, + "system_prompt": "You are the intake processor. Data has been pre-collected. Call set_output 3 times with the provided job_description, resume_files, and notify_candidates values.", + "tools": [], + "model": null, + "function": null, + "routes": {}, + "max_retries": 3, + "retry_on": [], + "max_node_visits": 1, + "output_model": null, + "max_validation_retries": 2, + "client_facing": false + }, + { + "id": "scan-resumes", + "name": "Scan Resumes", + "description": "Enumerate PDF files in the resume folder, read each one using pdf_read, and extract candidate text data.", + "node_type": "event_loop", + "input_keys": [ + "job_description", + "resume_files" + ], + "output_keys": [ + "resume_data" + ], + "nullable_output_keys": [], + "input_schema": {}, + "output_schema": {}, + "system_prompt": "You are the resume scanner. Use list_dir to enumerate PDFs, then pdf_read on each to extract text. Output structured JSON with all candidate data via set_output(\"resume_data\", ...).", + "tools": [ + "pdf_read", + "list_dir" + ], + "model": null, + "function": null, + "routes": {}, + "max_retries": 3, + "retry_on": [], + "max_node_visits": 1, + "output_model": null, + "max_validation_retries": 2, + "client_facing": false + }, + { + "id": "rank-candidates", + "name": "Rank Candidates", + "description": "Score each candidate's resume from 1-100 against the job description requirements, then rank them.", + "node_type": "event_loop", + "input_keys": [ + "job_description", + "resume_data" + ], + "output_keys": [ + "rankings" + ], + "nullable_output_keys": [], + "input_schema": {}, + "output_schema": {}, + "system_prompt": "You are the candidate evaluator. Score each resume 1-100 using: Skills Match (40%), Experience (25%), Education (15%), Communication (10%), Bonus (10%). Output ranked JSON via set_output(\"rankings\", ...).", + "tools": [], + "model": null, + "function": null, + "routes": {}, + "max_retries": 3, + "retry_on": [], + "max_node_visits": 1, + "output_model": null, + "max_validation_retries": 2, + "client_facing": false + }, + { + "id": "generate-report", + "name": "Generate Report", + "description": "Build a plain text screening report of ranked candidates.", + "node_type": "event_loop", + "input_keys": [ + "rankings" + ], + "output_keys": [ + "report_file" + ], + "nullable_output_keys": [], + "input_schema": {}, + "output_schema": {}, + "system_prompt": "You are a report generator. Write a plain text screening report using save_data, then call set_output with key report_file.", + "tools": [ + "save_data" + ], + "model": null, + "function": null, + "routes": {}, + "max_retries": 3, + "retry_on": [], + "max_node_visits": 1, + "output_model": null, + "max_validation_retries": 2, + "client_facing": false + }, + { + "id": "notify-candidates", + "name": "Notify Candidates", + "description": "Draft and send personalized response emails to candidates. The user reviews all emails before they are sent.", + "node_type": "event_loop", + "input_keys": [ + "rankings", + "notify_candidates" + ], + "output_keys": [ + "emails_sent" + ], + "nullable_output_keys": [], + "input_schema": {}, + "output_schema": {}, + "system_prompt": "You are the candidate notification assistant. If notify_candidates is 'no', skip. Otherwise draft personalized interview invitation (Top 5) and polite rejection (others) emails. Present all to user for approval before sending via send_email.", + "tools": [ + "send_email" + ], + "model": null, + "function": null, + "routes": {}, + "max_retries": 3, + "retry_on": [], + "max_node_visits": 1, + "output_model": null, + "max_validation_retries": 2, + "client_facing": true + } + ], + "edges": [ + { + "id": "intake-to-scan-resumes", + "source": "intake", + "target": "scan-resumes", + "condition": "on_success", + "condition_expr": null, + "priority": 1, + "input_mapping": {} + }, + { + "id": "scan-resumes-to-rank-candidates", + "source": "scan-resumes", + "target": "rank-candidates", + "condition": "on_success", + "condition_expr": null, + "priority": 1, + "input_mapping": {} + }, + { + "id": "rank-candidates-to-generate-report", + "source": "rank-candidates", + "target": "generate-report", + "condition": "on_success", + "condition_expr": null, + "priority": 1, + "input_mapping": {} + }, + { + "id": "generate-report-to-notify-candidates", + "source": "generate-report", + "target": "notify-candidates", + "condition": "on_success", + "condition_expr": null, + "priority": 1, + "input_mapping": {} + } + ], + "max_steps": 100, + "max_retries_per_node": 3, + "description": "Screen PDF resumes against a job description, score and rank candidates, produce a Top-5 report, and optionally send response emails.", + "created_at": "2026-02-21T11:21:00.000000" + }, + "goal": { + "id": "hr-screening", + "name": "Automated HR Screener", + "description": "Screen PDF resumes against a job description, score and rank candidates 1-100, produce a Top-5 report, and optionally send personalized response emails — all while keeping applicant data private on the local machine.", + "status": "draft", + "success_criteria": [ + { + "id": "sc-resumes-scanned", + "description": "Successfully reads and extracts text from all PDF resumes in the folder", + "metric": "Resumes successfully scanned", + "target": ">=1", + "weight": 0.2, + "met": false + }, + { + "id": "sc-candidates-scored", + "description": "Scores every candidate 1-100 with clear reasoning", + "metric": "Percentage of candidates scored", + "target": "100%", + "weight": 0.25, + "met": false + }, + { + "id": "sc-top5-report", + "description": "Produces a plain text report of the Top 5 candidates", + "metric": "Report generated and delivered", + "target": "Yes", + "weight": 0.25, + "met": false + }, + { + "id": "sc-privacy-compliance", + "description": "All processing done locally without transmitting PII externally", + "metric": "No external PII transmission", + "target": "Yes", + "weight": 0.15, + "met": false + }, + { + "id": "sc-email-delivery", + "description": "Sends response emails to candidates if user opted in", + "metric": "Emails handled correctly", + "target": "Yes", + "weight": 0.15, + "met": false + } + ], + "constraints": [ + { + "id": "c-no-fabrication", + "description": "Never fabricate resume content, scores, or candidate information", + "constraint_type": "hard", + "category": "quality", + "check": "" + }, + { + "id": "c-privacy", + "description": "Never transmit raw resume content to external services", + "constraint_type": "hard", + "category": "privacy", + "check": "" + }, + { + "id": "c-no-unsolicited-email", + "description": "Never send emails without explicit user approval", + "constraint_type": "hard", + "category": "safety", + "check": "" + }, + { + "id": "c-objective-scoring", + "description": "Apply scoring criteria consistently across all candidates", + "constraint_type": "hard", + "category": "quality", + "check": "" + } + ], + "context": {}, + "required_capabilities": [], + "input_schema": {}, + "output_schema": {}, + "version": "1.0.0", + "parent_version": null, + "evolution_reason": null, + "created_at": "2026-02-21 11:21:00.000000", + "updated_at": "2026-02-21 11:21:00.000000" + }, + "required_tools": [ + "pdf_read", + "list_dir", + "save_data", + "send_email" + ], + "metadata": { + "created_at": "2026-02-21T11:21:00.000000", + "node_count": 5, + "edge_count": 4 + } +} \ No newline at end of file diff --git a/examples/templates/hr_screener/agent.py b/examples/templates/hr_screener/agent.py new file mode 100644 index 0000000000..f4040eca46 --- /dev/null +++ b/examples/templates/hr_screener/agent.py @@ -0,0 +1,325 @@ +"""Agent graph construction for Automated HR Screener.""" + +from framework.graph import EdgeSpec, EdgeCondition, Goal, SuccessCriterion, Constraint +from framework.graph.edge import GraphSpec +from framework.graph.executor import ExecutionResult, GraphExecutor +from framework.runtime.event_bus import EventBus +from framework.runtime.core import Runtime +from framework.llm import LiteLLMProvider +from framework.runner.tool_registry import ToolRegistry + +from .config import default_config, metadata +from .nodes import ( + intake_node, + scan_resumes_node, + rank_candidates_node, + generate_report_node, + notify_candidates_node, +) + +# Goal definition +goal = Goal( + id="hr-screening", + name="Automated HR Screener", + description=( + "Screen PDF resumes against a job description, score and rank " + "candidates 1-100, produce a Top-5 report, and optionally send " + "personalized response emails — all while keeping applicant " + "data private on the local machine." + ), + success_criteria=[ + SuccessCriterion( + id="sc-resumes-scanned", + description="Successfully reads and extracts text from all PDF resumes in the folder", + metric="resumes_scanned", + target=">=1", + weight=0.2, + ), + SuccessCriterion( + id="sc-candidates-scored", + description="Scores every candidate 1-100 with clear reasoning", + metric="candidates_scored", + target="100%", + weight=0.25, + ), + SuccessCriterion( + id="sc-top5-report", + description="Produces a plain text report of the Top 5 candidates", + metric="report_generated", + target="true", + weight=0.25, + ), + SuccessCriterion( + id="sc-privacy-compliance", + description="All processing done locally without transmitting PII externally", + metric="privacy_compliant", + target="true", + weight=0.15, + ), + SuccessCriterion( + id="sc-email-delivery", + description="Sends response emails to candidates if user opted in", + metric="emails_handled", + target="true", + weight=0.15, + ), + ], + constraints=[ + Constraint( + id="c-no-fabrication", + description="Never fabricate resume content, scores, or candidate information", + constraint_type="hard", + category="quality", + ), + Constraint( + id="c-privacy", + description="Never transmit raw resume content to external services", + constraint_type="hard", + category="privacy", + ), + Constraint( + id="c-no-unsolicited-email", + description="Never send emails without explicit user approval", + constraint_type="hard", + category="safety", + ), + Constraint( + id="c-objective-scoring", + description="Apply scoring criteria consistently across all candidates", + constraint_type="hard", + category="quality", + ), + ], +) + +# Node list +nodes = [ + intake_node, + scan_resumes_node, + rank_candidates_node, + generate_report_node, + notify_candidates_node, +] + +# Edge definitions +edges = [ + EdgeSpec( + id="intake-to-scan-resumes", + source="intake", + target="scan-resumes", + condition=EdgeCondition.ON_SUCCESS, + priority=1, + ), + EdgeSpec( + id="scan-resumes-to-rank-candidates", + source="scan-resumes", + target="rank-candidates", + condition=EdgeCondition.ON_SUCCESS, + priority=1, + ), + EdgeSpec( + id="rank-candidates-to-generate-report", + source="rank-candidates", + target="generate-report", + condition=EdgeCondition.ON_SUCCESS, + priority=1, + ), + EdgeSpec( + id="generate-report-to-notify-candidates", + source="generate-report", + target="notify-candidates", + condition=EdgeCondition.ON_SUCCESS, + priority=1, + ), +] + +# Graph configuration +entry_node = "intake" +entry_points = {"start": "intake"} +pause_nodes = [] +terminal_nodes = ["notify-candidates"] + + +class HRScreenerAgent: + """ + Automated HR Screener — 5-node pipeline. + + Flow: intake -> scan-resumes -> rank-candidates -> generate-report -> notify-candidates + """ + + 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._executor: GraphExecutor | None = None + self._graph: GraphSpec | None = None + self._event_bus: EventBus | None = None + self._tool_registry: ToolRegistry | None = None + + def _build_graph(self) -> GraphSpec: + """Build the GraphSpec.""" + from .nodes import _build_intake_prompt + + # Override intake prompt at runtime when env vars are set + for node in self.nodes: + if node.id == "intake": + node.system_prompt = _build_intake_prompt() + break + return GraphSpec( + id="hr-screener-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, + conversation_mode="continuous", + identity_prompt="You are a rigorous and highly professional corporate human resources and engineering evaluation agent. You evaluate candidates based strictly on merit without fabrication, adhering to privacy constraints.", + loop_config={ + "max_iterations": 50, + "max_tool_calls_per_turn": 5, + "max_history_tokens": 4000, + }, + ) + + def _setup(self) -> GraphExecutor: + """Set up the executor with all components.""" + from pathlib import Path + + storage_path = Path.home() / ".hive" / "hr_screener" + storage_path.mkdir(parents=True, exist_ok=True) + + self._event_bus = EventBus() + self._tool_registry = ToolRegistry() + + mcp_config_path = Path(__file__).parent / "mcp_servers.json" + if mcp_config_path.exists(): + self._tool_registry.load_mcp_config(mcp_config_path) + + llm = LiteLLMProvider( + model=self.config.model, + api_key=self.config.api_key, + api_base=self.config.api_base, + ) + + tool_executor = self._tool_registry.get_executor() + tools = list(self._tool_registry.get_tools().values()) + + self._graph = self._build_graph() + runtime = Runtime(storage_path) + + self._executor = GraphExecutor( + runtime=runtime, + llm=llm, + tools=tools, + tool_executor=tool_executor, + event_bus=self._event_bus, + storage_path=storage_path, + loop_config=self._graph.loop_config, + ) + + return self._executor + + async def start(self) -> None: + """Set up the agent (initialize executor and tools).""" + if self._executor is None: + self._setup() + + async def stop(self) -> None: + """Clean up resources.""" + self._executor = None + self._event_bus = None + + async def trigger_and_wait( + self, + entry_point: str, + input_data: dict, + timeout: float | None = None, + session_state: dict | None = None, + ) -> ExecutionResult | None: + """Execute the graph and wait for completion.""" + if self._executor is None: + raise RuntimeError("Agent not started. Call start() first.") + if self._graph is None: + raise RuntimeError("Graph not built. Call start() first.") + + return await self._executor.execute( + graph=self._graph, + goal=self.goal, + input_data=input_data, + session_state=session_state, + ) + + async def run(self, context: dict, session_state=None) -> ExecutionResult: + """Run the agent (convenience method for single execution).""" + await self.start() + try: + result = await self.trigger_and_wait( + "start", context, session_state=session_state + ) + return result or ExecutionResult(success=False, error="Execution timeout") + finally: + await self.stop() + + def info(self): + """Get agent information.""" + 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, + "pause_nodes": self.pause_nodes, + "terminal_nodes": self.terminal_nodes, + "client_facing_nodes": [n.id for n in self.nodes if n.client_facing], + } + + def validate(self): + """Validate agent structure.""" + errors = [] + warnings = [] + + node_ids = {node.id for node in self.nodes} + for edge in self.edges: + if edge.source not in node_ids: + errors.append(f"Edge {edge.id}: source '{edge.source}' not found") + if edge.target not in node_ids: + errors.append(f"Edge {edge.id}: target '{edge.target}' not found") + + if self.entry_node not in node_ids: + errors.append(f"Entry node '{self.entry_node}' not found") + + for terminal in self.terminal_nodes: + if terminal not in node_ids: + errors.append(f"Terminal node '{terminal}' not found") + + for ep_id, node_id in self.entry_points.items(): + if node_id not in node_ids: + errors.append( + f"Entry point '{ep_id}' references unknown node '{node_id}'" + ) + + return { + "valid": len(errors) == 0, + "errors": errors, + "warnings": warnings, + } + + +# Create default instance +default_agent = HRScreenerAgent() diff --git a/examples/templates/hr_screener/config.py b/examples/templates/hr_screener/config.py new file mode 100644 index 0000000000..07c76705fd --- /dev/null +++ b/examples/templates/hr_screener/config.py @@ -0,0 +1,26 @@ +"""Runtime configuration.""" + +from dataclasses import dataclass + +from framework.config import RuntimeConfig + +default_config = RuntimeConfig() + + +@dataclass +class AgentMetadata: + name: str = "Automated HR Screener" + version: str = "1.0.0" + description: str = ( + "Screen PDF resumes against a job description, score and rank candidates, " + "produce a Top-5 report, and optionally send response emails. " + "Designed for local LLM usage to keep applicant PII private." + ) + intro_message: str = ( + "Hi! I'm your HR screening assistant. Give me a job description and a folder " + "of PDF resumes, and I'll score each candidate, rank them, and produce a clean " + "report of your top picks. Ready when you are!" + ) + + +metadata = AgentMetadata() diff --git a/examples/templates/hr_screener/mcp_servers.json b/examples/templates/hr_screener/mcp_servers.json new file mode 100644 index 0000000000..b326696a8e --- /dev/null +++ b/examples/templates/hr_screener/mcp_servers.json @@ -0,0 +1,9 @@ +{ + "hive-tools": { + "transport": "stdio", + "command": "uv", + "args": ["run", "python", "mcp_server.py", "--stdio"], + "cwd": "../../../tools", + "description": "Hive tools MCP server providing pdf_read, list_directory, save_data, send_email, and serve_file_to_user" + } +} diff --git a/examples/templates/hr_screener/nodes/__init__.py b/examples/templates/hr_screener/nodes/__init__.py new file mode 100644 index 0000000000..b607b92839 --- /dev/null +++ b/examples/templates/hr_screener/nodes/__init__.py @@ -0,0 +1,190 @@ +"""Node definitions for Automated HR Screener.""" + +import os + +from framework.graph import NodeSpec + + +def _build_intake_prompt(): + """Build intake prompt with pre-filled data from env vars.""" + jd = os.environ.get("HR_JOB_DESCRIPTION", "") + rf = os.environ.get("HR_RESUME_FILES", "") + nc = os.environ.get("HR_NOTIFY_CANDIDATES", "no") + if jd and rf: + return f"""You are the intake processor. Data has been pre-collected. + +Call set_output 3 times with this exact data: +1. set_output(key="job_description", value="{jd}") +2. set_output(key="resume_files", value="{rf}") +3. set_output(key="notify_candidates", value="{nc}") + +Do NOT modify the values. Do NOT ask questions. Do NOT output text.""" + return """You are the intake processor. Ask the user for: +1. Job description +2. Resume PDF path +3. Email notification preference (yes/no) +Then call set_output for each.""" + + +# Node 1: Intake +intake_node = NodeSpec( + id="intake", + name="Intake", + description=( + "Collect job description, resume file paths, " + "and email notification preference from the user." + ), + node_type="event_loop", + client_facing=False, + input_keys=[], + output_keys=["job_description", "resume_files", "notify_candidates"], + system_prompt=_build_intake_prompt(), + tools=[], +) + +# Node 2: Scan Resumes +# Reads each PDF using pdf_read and collects extracted text. +scan_resumes_node = NodeSpec( + id="scan-resumes", + name="Scan Resumes", + description="Read each PDF resume using pdf_read and collect text data.", + node_type="event_loop", + client_facing=False, + input_keys=["job_description", "resume_files"], + output_keys=["resume_data"], + system_prompt="""\ +You are a Senior Technical Recruiter specializing in parsing and analyzing engineering resumes. + +The "resume_files" context contains comma-separated PDF file paths. + +For EACH file path in the list: +1. Call pdf_read(file_path="") +2. Remember the result + +After reading ALL files, use the `set_output` tool with key "resume_data" to save a JSON string: +{"candidates": [{"file_name": "name.pdf", "text": ""}, ...], "total_resumes": N} + +RULES: +- Call pdf_read for EACH file. Do NOT skip any. +- Include the REAL text from pdf_read. NEVER fabricate content. +- If a pdf_read fails, include: {"file_name": "x.pdf", "text": "", "error": "reason"} +- Only use the set_output tool AFTER reading ALL files. +""", + tools=["list_dir", "pdf_read"], +) + +# Node 3: Rank Candidates +rank_candidates_node = NodeSpec( + id="rank-candidates", + name="Rank Candidates", + description="Score and rank all candidates against the job description.", + node_type="event_loop", + client_facing=False, + input_keys=["job_description", "resume_data"], + output_keys=["rankings"], + system_prompt="""\ +You are a Senior Principal Engineer responsible for rigorously evaluating and ranking engineering candidates based on technical merit. + +CHECK FIRST: If resume_data has no candidates or all have empty text: +- Use the `set_output` tool with key "rankings" to save: '{"error": "No data", "rankings": [], "total_candidates": 0}' +- STOP. Do NOT fabricate candidates. + +For each candidate, read their ACTUAL text and score (0-100): +- Skills Match (40%): skills vs job requirements +- Experience (25%): relevant experience level +- Education (15%): relevant degrees/certs +- Communication (10%): resume quality +- Bonus (10%): projects, publications, etc. + +Use the `set_output` tool with key "rankings" to save a JSON string: +{"rankings": [{"rank": 1, "file_name": "x.pdf", "candidate_name": "Name", \ +"overall_score": 85, "scores": {"skills_match": 90, "experience": 80, \ +"education": 75, "communication": 85, "bonus_factors": 70}, \ +"summary": "2 sentences", "top_skills": ["a","b"], "email": "x@y.com or null"}], \ +"total_candidates": N} + +RULES: +- NEVER fabricate. ALL data from actual resume text only. +- For the `email` field, extract the EXACT email address as it appears in the text using strict pattern matching. Do not misspell it or fabricate it. +- Score ALL candidates, sort by score descending. +""", + tools=[], +) + +# Node 4: Generate Report +generate_report_node = NodeSpec( + id="generate-report", + name="Generate Report", + description="Build a plain text screening report of ranked candidates.", + node_type="event_loop", + client_facing=False, + input_keys=["rankings"], + output_keys=["report_file"], + system_prompt="""\ +You are a report generator. Write a plain text screening report. + +Do EXACTLY these 2 steps: + +Step 1: Call save_data with filename="hr_screening_report.txt" and data = a structured plain text report. Format: + +HR SCREENING REPORT +=================== +Date: +Total Candidates: + +RANKINGS: +1. | Score: /100 | Email: + Strengths: <1-2 sentences from summary> + Top Skills: + +2. +... + +RECOMMENDATION: is the strongest fit. + +Step 2: Call set_output with key="report_file" and value="hr_screening_report.txt" + +ONLY 2 tool calls. No HTML. No append_data. No serve_file_to_user. STOP after set_output. +""", + tools=["save_data"], +) + +# Node 5: Notify Candidates (client-facing) +notify_candidates_node = NodeSpec( + id="notify-candidates", + name="Notify Candidates", + description="Send response emails to candidates after user approval.", + node_type="event_loop", + client_facing=True, + input_keys=["rankings", "notify_candidates"], + output_keys=["emails_sent"], + system_prompt="""\ +You are the email notification assistant. + +If notify_candidates is "no": + Call set_output(key="emails_sent", value="skipped") and STOP. + +If "yes": +1. Show the user a summary of candidates with real emails from rankings. + Ask: "Type YES to send emails." +2. If user confirms, for EACH candidate with a real email: + Call send_email(provider="resend", from_email="onboarding@resend.dev", + to=, subject="Your Application Update", + html="

Dear ,

Thank you for applying.

Best regards,
Recruitment Team

") +3. Call set_output(key="emails_sent", value="Sent to: ") + +RULES: +- ONLY use real emails from rankings. NEVER use example.com or placeholder emails. +- Call set_output EXACTLY ONCE at the end. +- If user declines, call set_output(key="emails_sent", value="cancelled"). +""", + tools=["send_email"], +) + +__all__ = [ + "intake_node", + "scan_resumes_node", + "rank_candidates_node", + "generate_report_node", + "notify_candidates_node", +] From 7aea779ef332545c5802e9954016a3ed7e9538ac Mon Sep 17 00:00:00 2001 From: vakrahul Date: Mon, 2 Mar 2026 21:22:47 +0530 Subject: [PATCH 2/5] style: fix ruff formatting for routes_events.py --- examples/templates/hr_screener/__main__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/templates/hr_screener/__main__.py b/examples/templates/hr_screener/__main__.py index 07bb0ffd82..0efe04005e 100644 --- a/examples/templates/hr_screener/__main__.py +++ b/examples/templates/hr_screener/__main__.py @@ -130,9 +130,7 @@ def tui(verbose, debug): # Check Resend key for email notifications resend_key = os.environ.get("RESEND_API_KEY", "").strip() if not resend_key: - print( - "\n ℹ RESEND_API_KEY not set — email notifications will be unavailable." - ) + print("\n ℹ RESEND_API_KEY not set — email notifications will be unavailable.") resend_input = input( " Enter Resend API key (or press Enter to skip): " ).strip() @@ -157,7 +155,7 @@ def tui(verbose, debug): jd_lines.append(line) job_description = "\n".join(jd_lines).strip() - # 2. Resume PDF path + # 2. Resume PDF patha print("\n🐝 [2/3] Resume File Path") while True: resume_path = ( From daf50ac996ecb67d881953f580fb36c4f634c2bc Mon Sep 17 00:00:00 2001 From: vakrahul Date: Mon, 2 Mar 2026 21:26:45 +0530 Subject: [PATCH 3/5] style: fix ruff formatting for routes_events.pys --- core/framework/storage/checkpoint_store.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/framework/storage/checkpoint_store.py b/core/framework/storage/checkpoint_store.py index 8c0d3983e5..843fc2e085 100644 --- a/core/framework/storage/checkpoint_store.py +++ b/core/framework/storage/checkpoint_store.py @@ -123,7 +123,9 @@ def _read() -> CheckpointIndex | None: return None try: - return CheckpointIndex.model_validate_json(self.index_path.read_text(encoding="utf-8")) + return CheckpointIndex.model_validate_json( + self.index_path.read_text(encoding="utf-8") + ) except Exception as e: logger.error(f"Failed to load checkpoint index: {e}") return None From d909866e1c29aab98fb276f8498d8bc9c7378801 Mon Sep 17 00:00:00 2001 From: vakrahul Date: Mon, 2 Mar 2026 21:48:07 +0530 Subject: [PATCH 4/5] style(core): format routes_events.py to pass CI --- core/framework/server/routes_events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/framework/server/routes_events.py b/core/framework/server/routes_events.py index 0ed6aac6b5..a7a541651c 100644 --- a/core/framework/server/routes_events.py +++ b/core/framework/server/routes_events.py @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) -# Default event types streamed to clients +# Default event types streamed to clientss DEFAULT_EVENT_TYPES = [ EventType.CLIENT_OUTPUT_DELTA, EventType.CLIENT_INPUT_REQUESTED, From 3b27506d692db5c944e3fa00f2e7560bc9bec69d Mon Sep 17 00:00:00 2001 From: vakrahul Date: Wed, 4 Mar 2026 00:00:54 +0530 Subject: [PATCH 5/5] fix(core): revert accidental formatting changes requested by review --- core/framework/server/routes_events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/framework/server/routes_events.py b/core/framework/server/routes_events.py index a7a541651c..0ed6aac6b5 100644 --- a/core/framework/server/routes_events.py +++ b/core/framework/server/routes_events.py @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) -# Default event types streamed to clientss +# Default event types streamed to clients DEFAULT_EVENT_TYPES = [ EventType.CLIENT_OUTPUT_DELTA, EventType.CLIENT_INPUT_REQUESTED,