diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index c73e032..ac1132c 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10"] + python-version: ["3.11"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} @@ -18,6 +18,8 @@ jobs: run: | python -m pip install --upgrade pip pip install pylint + pip install -e . - name: Analysing the code with pylint run: | - pylint $(git ls-files '*.py') + pylint $(git ls-files '*.py' | grep -v '^alembic/') + diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..80b189b --- /dev/null +++ b/.pylintrc @@ -0,0 +1,28 @@ +[MESSAGES CONTROL] +disable = + # Intentional patterns in framework callbacks and stubs + unused-argument, + unnecessary-ellipsis, + unnecessary-pass, + # Re-exports and future imports + unused-import, + # Enum members are intentionally lowercase for str enums (e.g. "email" = "email") + # Module-level private vars (_client, _config_snapshot) are not constants + invalid-name, + # Singleton pattern using module-level global is intentional in http_client + global-statement, + # Broad exception catches are guarded and intentional in dispatcher/llm client + broad-exception-caught, + # LLMClient._call_once is private; public surface is intentionally minimal + too-few-public-methods, + # Duplicate structlog processor chains across modules — acceptable for now + duplicate-code, + # Complex CLI setup wizard function — refactor deferred + too-many-branches, + too-many-statements, + +[FORMAT] +max-line-length = 120 + +[DESIGN] +max-args = 10 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a02619a --- /dev/null +++ b/README.md @@ -0,0 +1,213 @@ +# INGOT — INtelligent Generation & Outreach Tool + +> Autonomous job-hunting via a team of AI agents: scout leads, research companies, match your skills, write personalised cold emails, and track replies — all from the command line. + +Most great jobs are never posted. Founders and hiring managers hire people they've *already talked to*. INGOT makes your search proactive by running a personal recruiting agency 24/7 — at a scale no human could maintain alone. + +--- + +## How it works + +INGOT orchestrates seven specialised agents in a pipeline: + +``` + ┌──────────────────────────┐ + │ ORCHESTRATOR AGENT │ + │ Routes tasks · TUI chat │ + └────────────┬─────────────┘ + ┌──────────┬───────────┼────────────┬──────────┐ + ▼ ▼ ▼ ▼ ▼ + SCOUT RESEARCH MATCHER WRITER OUTREACH + discovers builds cross-refs drafts sends + + leads intel your resume emails tracks + └──────────────► ANALYST + reports +``` + +| Agent | Responsibility | +|---|---| +| **Scout** | Discovers leads from configured venues (YC, Apollo, LinkedIn, etc.) | +| **Research** | Scrapes company pages, press releases, and public profiles for talking points | +| **Matcher** | Scores each lead against your resume and generates a personalised value proposition | +| **Writer** | Drafts a 150–200 word cold email grounded in the research and your qualifications | +| **Outreach** | Manages sending (rate limiting, business-hours windows) and tracks opens/replies | +| **Analyst** | Surfaces campaign insights and weekly digests | +| **Orchestrator** | User-facing chat interface that routes requests across the team | + +--- + +## Prerequisites + +| Requirement | Notes | +|---|---| +| Python ≥ 3.11 | `python --version` to verify | +| [uv](https://docs.astral.sh/uv/) or pip | Package manager | +| Gmail account + [App Password](https://support.google.com/accounts/answer/185833) | For sending/receiving emails | +| **One of:** Ollama (free) · Anthropic API key · OpenAI API key | LLM backend | + +### Optional: Ollama (fully free, local) + +```bash +# macOS +brew install ollama +ollama serve # start the server (keep running) +ollama pull llama3.1 # download the default model (~4 GB) +``` + +--- + +## Installation + +```bash +# Clone the repository +git clone https://github.com/your-username/ingot.git +cd ingot + +# Install with uv (recommended) +uv sync + +# — or — install with pip in a virtual environment +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e . +``` + +After installation the `ingot` command is available in your environment: + +```bash +ingot --help +``` + +--- + +## Setup + +Run the interactive setup wizard to configure credentials and choose your LLM backend: + +```bash +ingot setup +``` + +The wizard walks through: + +1. **Gmail address** — used to send outreach emails +2. **Gmail App Password** — [generate one here](https://support.google.com/accounts/answer/185833) (not your regular password) +3. **Mailing address** — required for CAN-SPAM compliance footer +4. **LLM backend** — choose a preset or configure each agent individually: + - `fully_free` — all agents use local Ollama (zero API cost) + - `best_quality` — Writer & Research use Claude Sonnet; others use Claude Haiku + - `custom` — pick a LiteLLM model string for each of the 7 agents + +Credentials are saved to `~/.ingot/config.json` with secrets Fernet-encrypted at rest. + +### Non-interactive setup (CI / scripting) + +```bash +export GMAIL_USERNAME="you@gmail.com" +export GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx" +export ANTHROPIC_API_KEY="sk-ant-..." # only needed for best_quality preset + +ingot setup --non-interactive --preset best_quality +``` + +### Re-run setup + +Running `ingot setup` again only prompts for fields that are not yet configured. Existing values are preserved. + +--- + +## Usage + +> **Note:** The full agent pipeline and TUI chat are under active development. Today the `setup` command is the stable entry point. + +```bash +# Show all available commands +ingot --help + +# Configure credentials and LLM models +ingot setup + +# Re-run with a different preset (leaves other fields intact) +ingot setup --preset fully_free + +# Verbose output (use -vv for debug level) +ingot setup -v +``` + +--- + +## Configuration reference + +Config file location: `~/.ingot/config.json` + +| Field | Description | Default | +|---|---|---| +| `smtp.host` | SMTP server host | `smtp.gmail.com` | +| `smtp.port` | SMTP port | `587` | +| `smtp.username` | Sending Gmail address | — | +| `smtp.password` | Gmail App Password (encrypted) | — | +| `imap.host` | IMAP server for reply polling | `imap.gmail.com` | +| `anthropic_api_key` | Anthropic API key (encrypted) | — | +| `openai_api_key` | OpenAI API key (encrypted) | — | +| `mailing_address` | Physical address for CAN-SPAM footer | — | +| `agents..model` | LiteLLM model string per agent | `ollama/llama3.1` | +| `max_retries` | LLM call retry limit | `3` | +| `llm_fallback_chain` | Ordered fallback backends | `["claude","openai","ollama"]` | + +### LiteLLM model strings + +INGOT uses [LiteLLM](https://docs.litellm.ai/) so any supported provider works: + +``` +ollama/llama3.1 # local Ollama +anthropic/claude-3-5-sonnet-20241022 # Anthropic +anthropic/claude-haiku-4-5-20251001 # Anthropic (cheaper) +openai/gpt-4o # OpenAI +openai/gpt-4o-mini # OpenAI (cheaper) +``` + +--- + +## Development + +```bash +# Install dev dependencies +uv sync --extra dev + +# Run the test suite (requires ≥ 80 % coverage) +pytest + +# Run with coverage report +pytest --cov=ingot --cov-report=html +open htmlcov/index.html + +# Database migrations (Alembic) +alembic upgrade head +``` + +### Project layout + +``` +src/ingot/ +├── agents/ # Seven specialist agents + base class +├── cli/ # Typer CLI (setup wizard) +├── config/ # Config schema, manager, and crypto helpers +├── db/ # SQLModel models + Alembic migrations +├── llm/ # LiteLLM client with fallback logic +├── dispatcher.py # Agent task dispatcher +└── http_client.py # Shared async HTTP client +``` + +--- + +## Security notes + +- Secrets (passwords, API keys) are **Fernet-encrypted** before being written to `config.json`. The encryption key is derived from a machine-local secret stored in `~/.ingot/`. +- Gmail App Passwords are used instead of your account password — you can revoke them independently at any time. +- INGOT never stores or transmits your credentials to any third party. + +--- + +## License + +MIT diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..28b9aa0 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = alembic +sqlalchemy.url = sqlite+aiosqlite:///%(here)s/outreach.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/__init__.py b/alembic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..80fb890 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,63 @@ +import asyncio +import sys +from logging.config import fileConfig +from pathlib import Path + +# Ensure the package is importable without requiring an editable install. +# `alembic` is typically run from the project root; src/ may not be on sys.path. +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import create_async_engine +from alembic import context +from sqlmodel import SQLModel + +# MUST import all models to register them in SQLModel.metadata before autogenerate +from ingot.db.models import ( # noqa: F401 + UserProfile, Lead, LeadContact, IntelBrief, Match, Email, FollowUp, + Campaign, AgentLog, Venue, OutreachMetric, UnsubscribedEmail, +) + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = SQLModel.metadata + + +def get_url() -> str: + from ingot.config.manager import ConfigManager + cm = ConfigManager() + return f"sqlite+aiosqlite:///{cm.get_db_path()}" + + +def run_migrations_offline() -> None: + url = get_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + url = get_url() + connectable = create_async_engine(url, poolclass=pool.NullPool) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..297f5eb --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +import sqlmodel +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/.gitkeep b/alembic/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/alembic/versions/149adcd94073_initial_schema.py b/alembic/versions/149adcd94073_initial_schema.py new file mode 100644 index 0000000..d5d6949 --- /dev/null +++ b/alembic/versions/149adcd94073_initial_schema.py @@ -0,0 +1,168 @@ +"""initial_schema + +Revision ID: 149adcd94073 +Revises: +Create Date: 2026-02-26 10:40:40.626828 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +revision: str = '149adcd94073' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('agentlog', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('agent_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('step_description', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('duration_ms', sa.Integer(), nullable=False), + sa.Column('error_message', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('input_tokens', sa.Integer(), nullable=False), + sa.Column('output_tokens', sa.Integer(), nullable=False), + sa.Column('cost_estimate', sa.Float(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('campaign', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('campaign_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('started_at', sa.DateTime(), nullable=True), + sa.Column('ended_at', sa.DateTime(), nullable=True), + sa.Column('total_leads', sa.Integer(), nullable=False), + sa.Column('total_sent', sa.Integer(), nullable=False), + sa.Column('total_replied', sa.Integer(), nullable=False), + sa.Column('status', sa.Enum('active', 'paused', 'completed', name='campaignstatus'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('lead', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('person_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('person_email', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('person_role', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('company_website', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('source_venue', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('status', sa.Enum('discovered', 'researching', 'matched', 'drafted', 'sent', 'replied', name='leadstatus'), nullable=False), + sa.Column('initial_score', sa.Float(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('outreachmetric', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('sent_today', sa.Integer(), nullable=False), + sa.Column('sent_this_hour', sa.Integer(), nullable=False), + sa.Column('bounce_count', sa.Integer(), nullable=False), + sa.Column('bounce_rate', sa.Float(), nullable=False), + sa.Column('last_sent_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('unsubscribedemail', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email_address', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('unsubscribe_reason', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('unsubscribed_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_unsubscribedemail_email_address'), 'unsubscribedemail', ['email_address'], unique=False) + op.create_table('userprofile', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('headline', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('skills', sa.JSON(), nullable=True), + sa.Column('experience', sa.JSON(), nullable=True), + sa.Column('education', sa.JSON(), nullable=True), + sa.Column('projects', sa.JSON(), nullable=True), + sa.Column('github_url', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('linkedin_url', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('resume_raw_text', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('venue', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('venue_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('venue_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('config_json', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('last_run_at', sa.DateTime(), nullable=True), + sa.Column('lead_count_discovered', sa.Integer(), nullable=False), + sa.Column('last_error', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('email', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('subject_a', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('subject_b', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('body', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('tone_adapted_for', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('mcq_answers_json', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('status', sa.Enum('drafted', 'approved', 'sent', 'bounced', 'opened', name='emailstatus'), nullable=False), + sa.Column('lead_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['lead_id'], ['lead.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('intelbrief', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('company_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('company_signals', sa.JSON(), nullable=True), + sa.Column('person_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('person_role', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('company_website', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('person_background', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('talking_points', sa.JSON(), nullable=True), + sa.Column('company_product_description', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('lead_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['lead_id'], ['lead.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('match', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('match_score', sa.Float(), nullable=False), + sa.Column('value_proposition', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('confidence_level', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('lead_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['lead_id'], ['lead.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('followup', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('parent_email_id', sa.Integer(), nullable=True), + sa.Column('scheduled_for_day', sa.Integer(), nullable=False), + sa.Column('body', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('status', sa.Enum('queued', 'sent', 'skipped', name='followupstatus'), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('sent_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['parent_email_id'], ['email.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('followup') + op.drop_table('match') + op.drop_table('intelbrief') + op.drop_table('email') + op.drop_table('venue') + op.drop_table('userprofile') + op.drop_index(op.f('ix_unsubscribedemail_email_address'), table_name='unsubscribedemail') + op.drop_table('unsubscribedemail') + op.drop_table('outreachmetric') + op.drop_table('lead') + op.drop_table('campaign') + op.drop_table('agentlog') + # ### end Alembic commands ### diff --git a/alembic/versions/a3f2e1d4b5c6_add_leadcontact_table.py b/alembic/versions/a3f2e1d4b5c6_add_leadcontact_table.py new file mode 100644 index 0000000..e4f75b2 --- /dev/null +++ b/alembic/versions/a3f2e1d4b5c6_add_leadcontact_table.py @@ -0,0 +1,42 @@ +"""add LeadContact table + +Revision ID: a3f2e1d4b5c6 +Revises: 149adcd94073 +Create Date: 2026-02-26 + +Adds DB-02b: LeadContact — typed contact details for a Lead. +contact_type is stored as String (SQLite has no native enum); valid values +are enforced by the ContactType enum in models.py: + professional: email, linkedin, phone, calendly + developer: github, stackoverflow + social: twitter, medium, substack, youtube + startup: angellist, crunchbase, producthunt + web: website, portfolio +""" +from alembic import op +import sqlalchemy as sa + +revision = "a3f2e1d4b5c6" +down_revision = "149adcd94073" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "leadcontact", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("lead_id", sa.Integer(), nullable=False), + sa.Column("contact_type", sa.String(), nullable=False), + sa.Column("value", sa.String(), nullable=False), + sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["lead_id"], ["lead.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_leadcontact_lead_id", "leadcontact", ["lead_id"]) + + +def downgrade() -> None: + op.drop_index("ix_leadcontact_lead_id", table_name="leadcontact") + op.drop_table("leadcontact") diff --git a/outreach-agent-plan.md b/outreach-agent-plan.md new file mode 100644 index 0000000..a752508 --- /dev/null +++ b/outreach-agent-plan.md @@ -0,0 +1,598 @@ +# OutreachAgent — Autonomous Job Outreach with Agent Teams + +## Core Philosophy + +Most great jobs are never posted. Founders and hiring managers hire people they've *already talked to* — people who reached out, showed genuine interest, and demonstrated they understood the company's problems. Traditional job search is reactive (apply → get ghosted). This tool makes it proactive. + +**The thesis:** Replace the job board with a personal recruiting agency that runs 24/7, researches every target company deeply, writes emails that sound genuinely human, cross-references the user's real qualifications against each opportunity, and learns from every reply — at a scale no human could maintain alone. + +**Principles:** +1. **Relevance over volume** — a targeted, researched email beats 100 generic ones +2. **Qualifications-aware** — every email is grounded in the user's actual skills and experience +3. **Relationships, not transactions** — track full lifecycle, nurture over weeks +4. **Continuous learning** — every open, click, and reply teaches the system what works +5. **Pluggable everything** — new venues, email providers, LLMs, and future modules are first-class +6. **Free-first** — every agent can run on a local OSS model (Ollama) with zero API cost +7. **Future-ready** — architecture is a job hunting *platform*, not just an email sender + +--- + +## Agent Team Architecture + +``` +┌────────────────────────────────────────────────────────────┐ +│ ORCHESTRATOR AGENT │ +│ User-facing. Routes tasks, synthesizes results, │ +│ maintains context across agent team. Lives in TUI chat. │ +└──────────┬─────────────────────────────────────────────────┘ + │ delegates to + ┌─────────┼──────────────────────────────────────┐ + ▼ ▼ ▼ ▼ ▼ ▼ +SCOUT RESEARCH MATCHER WRITER OUTREACH ANALYST +AGENT AGENT AGENT AGENT AGENT AGENT +``` + +### Agent Team Details + +**1. Orchestrator Agent** (`agent/orchestrator.py`) +- User-facing: handles natural language in TUI chat +- Routes tasks to specialists, synthesizes output +- Maintains campaign memory and user context across sessions +- Example: "Find CTOs at YC fintech startups" → Scout → Research → Matcher → Writer → Outreach pipeline + +**2. Scout Agent** (`agent/scout.py`) +- Discovers leads from all configured venues in parallel +- Deduplicates across venues (same person found on Apollo + LinkedIn) +- Initial lead scoring (company size, funding stage, role relevance) +- Tools: `scrape_venue()`, `deduplicate_leads()`, `score_lead()` + +**3. Research Agent** (`agent/research.py`) +- Builds deep intelligence brief per lead +- Scrapes company homepage, About page, blog posts, press releases +- Checks person's LinkedIn, GitHub (public), Twitter activity +- Detects signals: recent funding, new product launch, open job postings +- Outputs: `IntelBrief` with company context and talking points +- Tools: `fetch_company_intel()`, `fetch_person_intel()`, `detect_signals()` + +**4. Matcher Agent** (`agent/matcher.py`) +- Cross-references the user's **UserProfile** (extracted from resume) against each lead +- Identifies skill/experience overlap: "Your Rust + trading systems experience matches their backend stack" +- Scores leads by qualification match (0–100) +- Generates a **personalized value proposition** for each lead: + - What skills make the user relevant to this specific company + - Which of their problems the user can solve + - Experience highlights to lead with +- Feeds value props directly to Writer Agent +- Tools: `match_skills()`, `score_qualifications()`, `generate_value_prop()` + +**5. Writer Agent** (`agent/writer.py`) +- Receives `Lead` + `IntelBrief` + `ValueProp` from Matcher +- Writes personalized cold email that references: + - Company's specific product/mission (from Intel) + - User's relevant experience (from ValueProp) +- Tone adapts by role: HR (professional), CEO (peer), CTO (technical) +- Generates 2 subject line variants for A/B testing +- Drafts follow-up sequence (Day 3, Day 7) for non-replies +- Enforces: 150–200 words, one clear ask, no buzzwords +- Tools: `compose_email()`, `generate_subject_variants()`, `compose_followup()` + +**6. Outreach Agent** (`agent/outreach.py`) +- Manages sending: rate limiting, business-hours-only send windows +- Polls Gmail IMAP for replies, classifies (positive / negative / auto-reply / OOO) +- On positive reply: notifies user, suggests response, optionally sends Calendly link +- Manages follow-up queue based on engagement signals +- Tools: `send_email()`, `poll_replies()`, `classify_reply()`, `schedule_followup()` + +**7. Analyst Agent** (`agent/analyst.py`) +- Daily or on-demand: reports on open rate, reply rate, best subject lines, best venues +- Identifies patterns: "Emails mentioning their product get 3x more replies" +- Feeds insights back to Writer Agent's system prompt context +- Tools: `query_metrics()`, `run_cohort_analysis()`, `generate_report()` + +--- + +## Pluggable LLM Backend + +### LLMClient Abstraction (`agent/llm_client.py`) +All agents use a single `LLMClient` interface — no agent directly imports `anthropic` or `openai`. The client handles tool-use, streaming, and retries regardless of backend. + +**Supported backends (out of the box):** +| Backend | How | Cost | +|---|---|---| +| Claude (Anthropic) | `anthropic` SDK | Pay-per-token | +| OpenAI / GPT | `openai` SDK (OpenAI-compatible API) | Pay-per-token | +| Ollama (local) | HTTP to `localhost:11434` | Free | +| LM Studio | OpenAI-compatible HTTP | Free | +| Any OpenAI-compatible API | Base URL override | Varies | + +### Per-Agent Model Config +Each agent has its own model setting in `~/.outreach-agent/config.json`. This lets you run cheap/free models where accuracy matters less, and reserve strong models for email writing: + +```json +{ + "agents": { + "orchestrator": { "backend": "ollama", "model": "llama3.2" }, + "scout": { "backend": "ollama", "model": "qwen2.5" }, + "research": { "backend": "claude", "model": "claude-haiku-4-5-20251001" }, + "matcher": { "backend": "ollama", "model": "llama3.2" }, + "writer": { "backend": "claude", "model": "claude-sonnet-4-6" }, + "outreach": { "backend": "ollama", "model": "qwen2.5" }, + "analyst": { "backend": "claude", "model": "claude-haiku-4-5-20251001" } + } +} +``` + +The setup wizard prompts for each agent's preferred backend and shows cost estimates. A **"fully free" preset** runs all agents on Ollama with `llama3.2` (requires Ollama installed locally). A **"best quality" preset** uses Claude Sonnet for Writer + Research and Haiku for the rest. + +### Tool-Use Compatibility +OSS models via Ollama that support tool-use natively (llama3.1+, qwen2.5, mistral-nemo) use structured JSON tool calls. For models without native tool-use support, the `LLMClient` falls back to a prompt-engineered tool-calling format (XML tags) with a local parser. + +--- + +## Resume Profile System + +### Setup Flow +During the setup wizard (`setup/wizard.py`), the user is prompted: +> "Upload your resume (PDF or DOCX) to personalize your outreach" + +The resume is parsed and stored as a structured `UserProfile` in the local DB: + +```python +UserProfile: + name, email, phone + headline # e.g., "Backend Engineer with 3 years in fintech" + skills[] # ["Rust", "Python", "distributed systems", "trading systems"] + experience[] # [{company, role, years, description}, ...] + education[] # [{school, degree, year}, ...] + projects[] # [{name, description, tech_stack[]}, ...] + github_url + linkedin_url + resume_raw_text # full text for LLM context +``` + +The Matcher Agent loads `UserProfile` on every matching run. The Writer Agent also has read access to construct accurate "here's why I'm relevant" paragraphs. + +### Resume Parsing +- `PyMuPDF` (fitz) for PDF parsing +- `python-docx` for DOCX +- Claude extracts structured fields from raw text (one-shot extraction call) + +--- + +## Future Module Architecture + +The platform is designed as a modular job hunting suite. Future modules slot in as new agents + TUI screens: + +``` +outreach-agent/ +└── modules/ # Future pluggable modules + ├── ats_checker/ # ATS score against job descriptions + │ └── agent.py # Analyzes JD keywords, scores resume fit, suggests edits + ├── interview_prep/ # Interview preparation agent + │ └── agent.py # Generates likely questions, mock answers from user profile + ├── job_board_scraper/ # Scrape actual job listings (LinkedIn Jobs, Indeed, Lever, Greenhouse) + │ └── agent.py + └── application_tracker/ # Track applied roles, status, deadlines + └── agent.py +``` + +Each module registers itself with the Orchestrator via a `ModuleRegistry`. The TUI adds a tab per active module. This means the core platform never needs to change to add ATS checking or interview prep — they're just new agents. + +--- + +## Installation & Local Setup + +### Directory Structure (post-install) +``` +~/.outreach-agent/ +├── config.json # Encrypted API keys, Gmail credentials, preferences +├── outreach.db # Primary SQLite database +├── logs/ +│ ├── agent.log # All agent activity +│ └── email.log # Email send/receive log +├── resume/ +│ └── resume.pdf # User's uploaded resume +└── venues/ + └── custom_venue.py # User-defined venue plugins +``` + +### Installation Steps +```bash +# Option A: pip install (future PyPI package) +pip install outreach-agent + +# Option B: clone + install (development) +git clone +cd outreach-agent +python -m venv .venv && source .venv/bin/activate +pip install -e ".[dev]" +playwright install chromium # headless browser for scraping +python main.py # launches setup wizard on first run +``` + +### Database: SQLite (primary, zero-dependency) +SQLite is the right choice for a single-user local tool: +- No server to run +- File-based, easily backed up +- Sufficient for tens of thousands of leads +- SQLModel provides async-compatible ORM + +**Migration tool:** Alembic handles schema migrations as the product evolves. + +**Optional Redis:** If the user enables multi-process workers in settings, Redis is used as the task queue backend instead of asyncio.Queue. Falls back gracefully if Redis is not available. + +### Environment & Secrets +All secrets stored encrypted in `~/.outreach-agent/config.json` using `cryptography` (Fernet symmetric encryption). Key derived from a local machine key (not stored in the repo or env file). + +`.env` is used only during development for overrides. + +--- + +## Project Structure + +``` +outreach-agent/ +├── main.py # Entry point: wizard or TUI +├── pyproject.toml # Dependencies + build config +├── agent/ +│ ├── base.py # BaseAgent: LLMClient + tool-use loop +│ ├── llm_client.py # LLMClient abstraction (Claude/OpenAI/Ollama/LMStudio) +│ ├── backends/ +│ │ ├── anthropic.py # Claude backend +│ │ ├── openai_compat.py # OpenAI + any OpenAI-compatible API +│ │ └── ollama.py # Ollama local backend +│ ├── orchestrator.py +│ ├── scout.py +│ ├── research.py +│ ├── matcher.py # Skills/qualifications matching +│ ├── writer.py +│ ├── outreach.py +│ ├── analyst.py +│ └── prompts/ # System prompt .md files per agent +├── discovery/ +│ ├── base.py # VenueBase abstract class +│ ├── ycombinator.py +│ ├── producthunt.py +│ ├── crunchbase.py +│ ├── angellist.py +│ ├── apollo.py +│ ├── hunter.py +│ ├── linkedin.py +│ └── registry.py # Auto-loads venues, plugin system +├── email_engine/ +│ ├── smtp.py # Gmail SMTP + rate limiter +│ ├── imap.py # Gmail IMAP reply poller +│ ├── tracker.py # Open pixel tracking +│ └── scheduler.py # APScheduler follow-up queue +├── profile/ +│ ├── parser.py # PDF/DOCX resume parser +│ └── extractor.py # Claude-powered structured extraction +├── setup/ +│ ├── wizard.py # First-run: Gmail, API keys, resume upload +│ └── venue_setup.py # Guided new venue creation wizard +├── tui/ +│ ├── app.py # Textual App +│ ├── screens/ +│ │ ├── dashboard.py # Stats + Orchestrator chat +│ │ ├── leads.py # Leads table with pipeline status + match scores +│ │ ├── emails.py # Email list + preview +│ │ └── settings.py # Config, venues, resume, API keys +│ └── widgets/ +│ ├── stats_panel.py +│ ├── chat_panel.py +│ └── activity_feed.py # Live event stream from all agents +├── queue/ +│ ├── dispatcher.py # Async task dispatcher +│ └── workers.py # Worker pool for agent jobs +├── modules/ # Future pluggable modules (empty stubs) +│ ├── __init__.py +│ ├── registry.py # ModuleRegistry for future modules +│ ├── ats_checker/ +│ ├── interview_prep/ +│ └── application_tracker/ +├── db/ +│ ├── models.py # All SQLModel tables +│ ├── queries.py # Helper query functions +│ └── migrations/ # Alembic migration files +└── config.py # ~/.outreach-agent/ config management +``` + +--- + +## Data Models + +```python +UserProfile: id, name, headline, skills[], experience[], education[], projects[], resume_path, updated_at +Lead: id, name, email, company, role, source_venue, score, match_score, value_prop, status, scraped_at +IntelBrief: id, lead_id, company_summary, person_summary, signals[], talking_points[], created_at +Email: id, lead_id, subject, body, subject_variant, sent_at, opened_at, replied_at, reply_type, reply_body +Campaign: id, name, query, venues[], status, lead_count, sent_count, reply_count, created_at +AgentLog: id, agent_name, task, input_summary, output_summary, tokens_used, duration_ms, created_at +Venue: id, name, enabled, config_json, last_scraped_at +``` + +--- + +## Extensibility Architecture + +The system is built around four extension points. Adding any future feature means implementing one or more of these interfaces — the core never changes. + +### 1. Event Bus (`queue/events.py`) +All significant state changes emit events. Any agent or module can subscribe. This is how new features plug in without touching existing code. + +```python +# Events emitted by core: +LeadDiscovered(lead_id, venue, score) +IntelBriefReady(lead_id) +ValuePropGenerated(lead_id) +EmailDrafted(lead_id, email_id) +EmailSent(email_id) +EmailOpened(email_id, timestamp) +ReplyReceived(email_id, reply_type, body) +FollowUpQueued(email_id, scheduled_at) +AgentCompleted(agent_name, task_id, output) + +# Future modules subscribe to events they care about: +# FundingMonitor subscribes to: nothing (polls externally) +# FundingMonitor emits: FundingSignalDetected(company) +# Scout subscribes to: FundingSignalDetected → triggers new scout run +``` + +This pub-sub pattern means the Funding Monitor, LinkedIn warm-up, or any future module just subscribes to the right events and emits new ones — no changes to Scout, Research, or Writer. + +### 2. Agent Registry (`agent/registry.py`) +Agents are registered by name. The Orchestrator routes tasks by consulting the registry, not a hardcoded if-else chain. + +```python +@agent_registry.register("scout") +class ScoutAgent(BaseAgent): ... + +@agent_registry.register("interview_prep") # future module, drops in +class InterviewPrepAgent(BaseAgent): ... +``` + +New agents register themselves on import. The Orchestrator picks them up automatically. + +### 3. Venue Plugin System (`discovery/registry.py`) +Already described above — `VenueBase` + auto-discovery. Any `.py` file in `discovery/` or `~/.outreach-agent/venues/` is loaded automatically. + +### 4. TUI Module System (`tui/module_registry.py`) +Modules can register new TUI screens and sidebar items. The Textual App queries the registry at startup and mounts registered screens. + +```python +@tui_registry.register_screen(tab_label="ATS", shortcut="F5") +class ATSCheckerScreen(Screen): ... # drops in when ats_checker module is enabled +``` + +### 5. Integration Layer (`integrations/`) +All third-party service calls go through named integration adapters. New services (Calendly, Notion, Slack) are added here without touching agent logic. + +``` +integrations/ +├── base.py # IntegrationBase +├── gmail.py # Gmail SMTP + IMAP +├── calendly.py # (future) Calendar link generation +├── notion.py # (future) Sync leads to Notion DB +├── slack.py # (future) Daily digest to Slack +└── registry.py # Auto-loads enabled integrations from config +``` + +### 6. Hook System (`hooks/`) +Lifecycle hooks let users add custom logic at key points (similar to git hooks) without modifying source: + +``` +~/.outreach-agent/hooks/ +├── on_lead_discovered.py # Custom scoring logic +├── on_email_drafted.py # Custom email post-processing +└── on_reply_received.py # Custom reply handling +``` + +--- + +## Future Features Backlog + +All features below are designed to slot into the extensibility architecture above. None require changes to the core pipeline. + +### Intelligence & Targeting +- **Tech stack detection** — scrape job postings + company GitHub org to infer stack; Matcher scores your skills against it (new Research Agent tool + Matcher skill) +- **Funding signal triggers** — monitor Crunchbase/PH for funding rounds; auto-queue Scout run when a company raises (new `FundingMonitor` module, subscribes/emits events) +- **Company news monitoring** — RSS/Google Alerts per target company; Writer Agent references recent press in opener (new Research tool + event) +- **Competitor mapping** — Research Agent builds competitive landscape for each target company's industry +- **GitHub contribution detection** — identify OSS repos at target companies relevant to user's skills; suggest contributing before emailing CTO + +### Relationship & Network +- **Warm intro finder** — scan LinkedIn connections for second-degree paths into target companies; Orchestrator prioritizes warm paths (new Research tool) +- **LinkedIn warm-up** — auto-engage with target's LinkedIn posts (like/comment via Playwright) before cold email (new `LinkedInWarmup` module + event hook) +- **Auto-LinkedIn connect** — connection request + personalized note before/after email (new Outreach tool) +- **Network graph view** — TUI screen: visual graph of you → connections → companies → targets (new TUI screen via module registry) + +### Email Intelligence +- **Smart send timing** — learn optimal send windows per industry/role from historical open data (Analyst Agent enhancement) +- **Subject line evolution** — winning subject lines fed back to Writer Agent system prompt automatically (Analyst → Writer feedback loop via event) +- **Reply draft assist** — on positive reply, Orchestrator drafts your response for review (new Outreach event handler) +- **Meeting detection** — detect "let's talk" intent in replies, inject Calendly link (new Outreach + Calendly integration) +- **Auto unsubscribe handler** — detect opt-out replies, mark do-not-contact permanently (new reply classifier label + Outreach handler) + +### Job Board & Career Module +- **Job posting scraper** — scrape LinkedIn Jobs, Greenhouse, Lever, Workable; combine with outreach ("saw you're hiring a backend engineer") (new Venue type: `job_board`) +- **ATS keyword optimizer** — given a JD, show which keywords your resume is missing (new `ATSChecker` module) +- **Interview prep** — generate likely questions + answers given company + role + UserProfile (new `InterviewPrep` module) +- **Application tracker** — log all applications, interview stages, decisions; pipeline view in TUI (new `ApplicationTracker` module + TUI screen) + +### Workflow & Control +- **Multi-account support** — manage multiple Gmail accounts per role/niche; route campaigns to specific accounts +- **Company blacklist/whitelist** — config-driven, checked by Scout before adding leads +- **Campaign templates** — save reusable campaign configs as named presets +- **`outreach add-lead `** — CLI quick-add from LinkedIn URL → fires Research + Matcher + Writer +- **Daily briefing** — morning digest: new replies, pending reviews, suggested actions (new Analyst event + Orchestrator hook) +- **Outreach health score** — daily score (0–100) in stats panel; tracks leads, send rate, reply rate, pipeline progress +- **Browser quick-add** — future browser extension to push profiles directly into Scout queue + +--- + +## Implementation Steps + +1. **Scaffold** — project structure, `pyproject.toml`, Alembic setup +2. **DB layer** — SQLModel models + migrations + `queries.py` +3. **Config system** — `~/.outreach-agent/` encrypted config +4. **Resume profile** — PDF/DOCX parser + Claude extraction → `UserProfile` +5. **Setup wizard** — Gmail SMTP/IMAP, API keys, resume upload, test send +6. **BaseAgent** — LLM client wrapper with tool-use loop (shared by all agents) +7. **VenueBase + YC scraper** — first working venue end-to-end +8. **Remaining venues** — Apollo, Hunter, ProductHunt, Crunchbase, AngelList, LinkedIn +9. **Venue plugin system** — registry + `--venue-setup` wizard +10. **Scout Agent** — parallel venue scraping, dedup, scoring +11. **Research Agent** — company + person intel, signal detection +12. **Matcher Agent** — UserProfile × IntelBrief → match score + value prop +13. **Writer Agent** — personalized email with value prop, A/B subjects, follow-ups +14. **Outreach Agent** — SMTP send + IMAP poll + reply classifier + follow-up scheduler +15. **Analyst Agent** — metrics, cohort analysis, insight feedback loop +16. **Orchestrator Agent** — pipeline coordination + user chat +17. **Async task queue** — dispatcher + worker pool wiring all agents +18. **TUI** — Textual app, all screens + match score in leads view + activity feed +19. **Module stubs** — empty `ats_checker`, `interview_prep`, `application_tracker` + `ModuleRegistry` +20. **Main entry point + installation script** — wizard → TUI → agent team startup + +--- + +## Dependencies + +```toml +[project.dependencies] +anthropic = ">=0.40.0" # Claude API backend +openai = ">=1.40.0" # OpenAI + OpenAI-compatible backends (Ollama, LMStudio) +textual = ">=0.70.0" # Terminal UI +playwright = ">=1.45.0" # Headless browser scraping +sqlmodel = ">=0.0.21" # SQLite ORM +alembic = ">=1.13.0" # DB migrations +httpx = ">=0.27.0" # Async HTTP client +beautifulsoup4 = ">=4.12.0" # HTML parsing +apscheduler = ">=3.10.0" # Follow-up scheduling +rich = ">=13.0.0" # Text formatting +click = ">=8.1.0" # CLI entry points +python-dotenv = ">=1.0.0" +cryptography = ">=42.0.0" # Encrypt stored secrets +pymupdf = ">=1.24.0" # PDF parsing +python-docx = ">=1.1.0" # DOCX parsing +redis = {version = ">=5.0.0", optional = true} # Optional task queue backend + +[project.optional-dependencies] +dev = ["pytest", "pytest-asyncio", "mypy", "ruff"] +``` + +**Note on Ollama:** Ollama uses an OpenAI-compatible REST API, so `openai` SDK handles it with a `base_url` override (`http://localhost:11434/v1`). No separate Ollama SDK needed. + +--- + +## CLI Command Interface + +The tool exposes a full `outreach` CLI for every operation — no need to open the TUI for quick checks. Commands are grouped by domain: + +### Agent Inspection +```bash +outreach agents list # List all agents + status (idle/running/error) +outreach agents logs # Tail recent agent activity across all agents +outreach agents logs --agent writer # Filter to specific agent +outreach agents inspect # Full run history + last input/output + token usage +``` + +### Data & Analytics +```bash +outreach data leads # Table of all leads (name, company, role, match score, status) +outreach data leads --venue yc --status new # Filter by venue and status +outreach data emails # All emails: sent, pending review, opened, replied +outreach data stats # Dashboard summary: sent, open rate, reply rate, interviews +outreach data export --format csv # Export leads or emails to CSV +outreach data lead # Full detail view of one lead + intel brief + email history +``` + +### Email Tracking & Review +```bash +outreach mail pending # Show emails in review queue awaiting approval +outreach mail review # Open email draft for inline editing before send +outreach mail approve # Approve and send immediately +outreach mail approve-all # Approve all pending (with confirmation prompt) +outreach mail reject # Reject draft, optionally regenerate +outreach mail track # Live view of opens, clicks, replies +outreach mail thread # Full email thread with a lead (sent + received) +``` + +### Manual Agent Triggers +```bash +outreach run scout --venue yc --query "fintech startups" --count 20 +outreach run research # Re-research a specific lead +outreach run match # Re-run Matcher on all unmatched leads +outreach run write # Regenerate email draft for a lead +outreach run followup # Queue follow-ups for all eligible leads +outreach run analyze # Run Analyst Agent and print report +``` + +### Config & Setup +```bash +outreach config show # Print current config (redacted secrets) +outreach config set --agent writer --backend claude --model claude-sonnet-4-6 +outreach config set --agent scout --backend ollama --model llama3.2 +outreach setup # Re-run setup wizard +outreach --venue-setup # Add a new discovery venue +``` + +--- + +## Human-in-the-Loop Mode + +The system defaults to **review-before-send**: every email draft goes into a **Review Queue** before sending. This keeps you in control of every word sent in your name. + +### Review Queue Flow +``` +Writer Agent drafts email + │ + ▼ +Review Queue (status: "pending_review") + │ + ├─ outreach mail review → open in editor, edit subject/body + ├─ outreach mail approve → send immediately + ├─ outreach mail reject → discard or regenerate + └─ In TUI: F3 Emails → Review tab → inline edit panel +``` + +### Step-by-Step Assist Mode +Enable in config (`"assist_mode": true`) to have the Orchestrator narrate every step and pause at configurable checkpoints: + +``` +[Scout] Found 8 leads from YC W25 fintech batch. +[Scout] Highest match: Sarah Chen (CTO, Fintex) — match score 87/100 + → Continue to research all 8? [Y/n/select] + +[Research] Compiled intel brief for Sarah Chen. + Key signal: Fintex raised $4M seed 3 weeks ago, actively hiring backend + → View full brief? [Y/n] + +[Matcher] Value prop generated: + "Your Rust + distributed systems experience directly matches Fintex's + backend challenges — they're building a real-time clearing engine." + → Edit value prop? [Y/n] + +[Writer] Draft email ready for review. + Subject A: "Rust dev excited about Fintex's clearing engine" + Subject B: "Quick note from a backend engineer" + → Open in review queue: `outreach mail review 42` +``` + +### Inline Edit in TUI +In the TUI Emails tab → Review subtab: +- Full email preview pane with editable subject, body +- Side panel shows the `IntelBrief` and `ValueProp` for reference while editing +- Keyboard shortcut: `e` to edit, `a` to approve, `r` to reject, `g` to regenerate + +--- + +## Verification + +- `python main.py` → setup wizard prompts for resume + Gmail + API keys +- `python main.py --venue-setup` → guided new venue creation +- TUI launches with stats panel, activity feed, agent chat +- Chat: "find 5 YC fintech startups and email their CTOs" → Scout → Research → Matcher → Writer → Outreach fires in sequence, visible in activity feed +- Leads tab shows match scores next to each lead +- Emails tab shows sent emails with open/reply status +- F4 Settings shows resume profile with extracted skills +- Analyst daily digest appears in chat after 24h diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c6cbe4f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,51 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ingot" +version = "0.1.0" +description = "INGOT — INtelligent Generation & Outreach Tool" +requires-python = ">=3.11" +dependencies = [ + "pydantic-ai>=0.0.14", + "litellm>=1.35", + "sqlmodel>=0.0.19", + "aiosqlite>=0.20", + "alembic>=1.14", + "cryptography>=44", + "tenacity>=9", + "pydantic>=2", + "typer>=0.15", + "rich>=14", + "questionary>=2", + "httpx>=0.28", + "platformdirs>=3", + "structlog>=25", + "aiosmtplib>=3", + "aioimaplib>=2", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "pytest-asyncio>=0.24", + "pytest-cov>=5", + "anyio>=4", + "httpx", +] + +[project.scripts] +ingot = "ingot.cli:app" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +addopts = "--cov=ingot --cov-report=term-missing --cov-fail-under=80" +testpaths = ["tests"] + +[tool.coverage.run] +source = ["ingot"] +omit = ["tests/*"] + +[tool.hatch.build.targets.wheel] +packages = ["src/ingot"] diff --git a/src/ingot/__init__.py b/src/ingot/__init__.py new file mode 100644 index 0000000..0dfdb6d --- /dev/null +++ b/src/ingot/__init__.py @@ -0,0 +1,3 @@ +"""INGOT — INtelligent Generation & Outreach Tool.""" + +__version__ = "0.1.0" diff --git a/src/ingot/agents/__init__.py b/src/ingot/agents/__init__.py new file mode 100644 index 0000000..e375d17 --- /dev/null +++ b/src/ingot/agents/__init__.py @@ -0,0 +1,46 @@ +""" +INGOT agent package. + +Importing this package triggers register_agent() for all 6 non-Orchestrator agents. +The Orchestrator class is available via ingot.agents.orchestrator.Orchestrator. +""" +# Import all agent modules to populate AGENT_REGISTRY at package import time. +from ingot.agents import ( # noqa: F401 + analyst, + matcher, + outreach, + research, + scout, + writer, +) +from ingot.agents.base import AgentBase, AgentDeps, AgentRunResult, StepResult +from ingot.agents.exceptions import ( + AgentError, + ConfigError, + DBError, + IngotError, + LLMError, + LLMValidationError, + ValidationError, +) +from ingot.agents.registry import AGENT_REGISTRY, get_agent, list_agents + +__all__ = [ + # deps / protocol / result types + "AgentDeps", + "AgentBase", + "StepResult", + "AgentRunResult", + # registry + "AGENT_REGISTRY", + "get_agent", + "list_agents", + # exceptions + "IngotError", + "LLMError", + "LLMValidationError", + "DBError", + "ConfigError", + "ValidationError", + "AgentError", +] diff --git a/src/ingot/agents/analyst.py b/src/ingot/agents/analyst.py new file mode 100644 index 0000000..58e1889 --- /dev/null +++ b/src/ingot/agents/analyst.py @@ -0,0 +1,106 @@ +# AGENT-05: This module MUST NOT import from other agent modules. +# Only Orchestrator coordinates between agents. +""" +Analyst agent — tracks campaign metrics and feeds insights back to Writer. + +Pipeline: aggregate → identify_patterns → generate_insights + +Tools the LLM can call during this pipeline: + - query_campaign_stats: pull open/reply/bounce counts from DB for a campaign + - compare_cohorts: compare two cohorts of emails (e.g., different subject variants) +""" +from __future__ import annotations + +from typing import ClassVar + +from pydantic_ai import Agent, RunContext + +from ingot.agents.base import AgentDeps, AgentRunResult, StepResult +from ingot.agents.registry import register_agent + +_agent: Agent[AgentDeps, str] = Agent( + "ollama:llama3.1", + deps_type=AgentDeps, + defer_model_check=True, + system_prompt=( + "You are a campaign analytics agent for INGOT. " + "Track open rates, reply rates, and response patterns. " + "Identify what resonates and produce actionable insights for the Writer agent. " + "Use query_campaign_stats to pull metrics and compare_cohorts to spot patterns." + ), +) + + +@_agent.tool +async def query_campaign_stats(ctx: RunContext[AgentDeps], campaign_id: int) -> dict: + """Return open/reply/bounce counts and rates for a campaign from the database.""" + # Phase 4: aggregate AgentLog + Email rows for the given campaign + raise NotImplementedError("Phase 4") + + +@_agent.tool +async def compare_cohorts( + ctx: RunContext[AgentDeps], cohort_a: str, cohort_b: str +) -> dict: + """ + Compare two named email cohorts (e.g., subject variant A vs B). + Returns delta metrics: open_rate_diff, reply_rate_diff, sample_sizes. + """ + # Phase 4: DB aggregation + statistical significance check + raise NotImplementedError("Phase 4") + + +class AnalystAgent: + """Aggregates campaign metrics, finds patterns, and generates Writer feedback.""" + + STEPS: ClassVar[list[str]] = ["aggregate", "identify_patterns", "generate_insights"] + + async def run( + self, + deps: AgentDeps, + prompt: str = "", + steps: list[str] | None = None, + **kwargs, + ) -> AgentRunResult: + """Execute the full pipeline or a specified subset of steps.""" + targets = steps if steps is not None else self.STEPS + completed: list[StepResult] = [] + for step in targets: + result = await self.run_step(step, deps, **kwargs) + completed.append(result) + if not result.success: + break + return AgentRunResult( + agent_name="analyst", + success=all(r.success for r in completed), + steps=completed, + final_output=completed[-1].output if completed else None, + ) + + async def run_step(self, step: str, deps: AgentDeps, **kwargs) -> StepResult: + """Dispatch a single named step to its implementation method.""" + match step: + case "aggregate": + return await self._aggregate(deps, **kwargs) + case "identify_patterns": + return await self._identify_patterns(deps, **kwargs) + case "generate_insights": + return await self._generate_insights(deps, **kwargs) + case _: + raise ValueError(f"Analyst has no step '{step}'. Valid: {self.STEPS}") + + async def _aggregate(self, deps: AgentDeps, **kwargs) -> StepResult: + # Phase 4: pull open/reply/bounce stats from DB + return StepResult(step="aggregate", success=True, output={}) + + async def _identify_patterns(self, deps: AgentDeps, **kwargs) -> StepResult: + # Phase 4: LLM + cohort comparison to find what subject lines/tones perform best + return StepResult(step="identify_patterns", success=True, output={}) + + async def _generate_insights(self, deps: AgentDeps, **kwargs) -> StepResult: + # Phase 4: structured insight dict fed back to Writer's context + return StepResult(step="generate_insights", success=True, output={}) + + +analyst = AnalystAgent() +register_agent("analyst", analyst) diff --git a/src/ingot/agents/base.py b/src/ingot/agents/base.py new file mode 100644 index 0000000..fb44ede --- /dev/null +++ b/src/ingot/agents/base.py @@ -0,0 +1,136 @@ +""" +Agent dependency injection types and pipeline contracts. + +All agents receive resources via AgentDeps — never via global state. +This makes agents testable (inject mocks) and independently deployable. + +AgentBase documents the contract every agent class must satisfy: + - STEPS: ordered pipeline steps the agent executes + - run_step(): execute one named step (enables checkpointing + retry) + - run(): execute the full pipeline in sequence +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, ClassVar, Protocol, runtime_checkable + +import httpx +from sqlalchemy.ext.asyncio import AsyncSession + +from ingot.llm.client import LLMClient + + +@dataclass +class AgentDeps: + """ + Dependency container injected into every agent via PydanticAI's deps_type. + Construct once per agent invocation; do not share across concurrent runs. + """ + + llm_client: LLMClient + session: AsyncSession + http_client: httpx.AsyncClient + verbosity: int = 0 # 0=normal, 1=-v, 2=-vv + agent_name: str = "" # Set by Orchestrator before dispatch + + +@dataclass +class StepResult: + """ + Result of one pipeline step. + + Agents return this from run_step(). Orchestrator uses it to decide + whether to continue, retry, or abort the pipeline. + """ + + step: str + success: bool + output: Any = None + error: Exception | None = None + + +@dataclass +class AgentRunResult: + """ + Full result of an agent pipeline run. + + Contains one StepResult per executed step. If a step fails, the pipeline + stops and subsequent steps are absent from `steps`. + """ + + agent_name: str + success: bool + steps: list[StepResult] = field(default_factory=list) + final_output: Any = None + + @property + def failed_step(self) -> StepResult | None: + """Return the first failed step, or None if all succeeded.""" + return next((s for s in self.steps if not s.success), None) + + +@runtime_checkable +class AgentBase(Protocol): + """ + Contract every INGOT agent class must satisfy. + + Agents are not bare PydanticAI Agent instances — they are classes that + wrap a PydanticAI Agent and expose a structured pipeline. This enables: + + 1. Tool/function calling — each agent registers @agent.tool functions + that the LLM can invoke during a step (web fetch, DB query, etc.) + + 2. Step sequences — STEPS declares the ordered pipeline. Orchestrator + can execute a subset (e.g., skip "score" if already scored), retry + a single step without rerunning the whole pipeline, or checkpoint + between steps for long-running runs. + + 3. Testability — run_step() can be tested in isolation with mock deps, + without standing up a full LLM or DB connection. + + Usage:: + + class ScoutAgent: + STEPS = ["discover", "deduplicate", "score"] + + async def run_step(self, step, deps, **kwargs) -> StepResult: ... + async def run(self, deps, prompt="", **kwargs) -> AgentRunResult: ... + + # Tools are registered on the module-level PydanticAI Agent: + @_agent.tool + async def fetch_venue_page(ctx: RunContext[AgentDeps], url: str) -> str: ... + """ + + STEPS: ClassVar[list[str]] + """ + Ordered list of pipeline step names. + Orchestrator may pass a subset to run() to skip completed steps. + """ + + async def run( + self, + deps: AgentDeps, + prompt: str = "", + steps: list[str] | None = None, + **kwargs: Any, + ) -> AgentRunResult: + """ + Execute the full pipeline (or a subset if `steps` is provided). + + Args: + deps: Injected resources. Never passed as global state. + prompt: Optional natural-language instruction for the run. + steps: If given, only execute these steps (must be subset of STEPS). + """ + ... + + async def run_step( + self, step: str, deps: AgentDeps, **kwargs: Any + ) -> StepResult: + """ + Execute a single named step. Must be in STEPS. + + Orchestrator calls this for checkpointing, retry, and partial runs. + Each step may invoke the agent's registered tools internally. + """ + ... diff --git a/src/ingot/agents/exceptions.py b/src/ingot/agents/exceptions.py new file mode 100644 index 0000000..515b112 --- /dev/null +++ b/src/ingot/agents/exceptions.py @@ -0,0 +1,57 @@ +""" +INGOT typed exception hierarchy. + +Rule: Never raise bare Exception. Always raise the most specific subclass. +Callers must catch specific types — catching IngotError is only acceptable +at the top-level CLI handler that formats user-visible error messages. +""" + + +class IngotError(Exception): + """Base exception for all INGOT errors. Carries a user-friendly message.""" + + def __init__(self, message: str, *, cause: Exception | None = None): + super().__init__(message) + self.message = message + self.cause = cause + + def __str__(self) -> str: + if self.cause: + return f"{self.message} (caused by: {type(self.cause).__name__}: {self.cause})" + return self.message + + +class LLMError(IngotError): + """LLM backend unreachable, timeout, or all retries exhausted.""" + pass + + +class LLMValidationError(IngotError): + """LLM returned a response that failed Pydantic validation.""" + + def __init__(self, message: str, *, raw_content: str = "", cause: Exception | None = None): + super().__init__(message, cause=cause) + self.raw_content = raw_content + + +class DBError(IngotError): + """Database read/write failure. Best-effort recovery may apply.""" + pass + + +class ConfigError(IngotError): + """Configuration missing, invalid, or encryption key lost.""" + pass + + +class ValidationError(IngotError): + """Input data failed schema validation (distinct from LLM response validation).""" + pass + + +class AgentError(IngotError): + """Agent-level failure (agent-specific logic error, not LLM or DB).""" + + def __init__(self, agent_name: str, message: str, *, cause: Exception | None = None): + super().__init__(f"[{agent_name}] {message}", cause=cause) + self.agent_name = agent_name diff --git a/src/ingot/agents/matcher.py b/src/ingot/agents/matcher.py new file mode 100644 index 0000000..9ad3e62 --- /dev/null +++ b/src/ingot/agents/matcher.py @@ -0,0 +1,102 @@ +# AGENT-05: This module MUST NOT import from other agent modules. +# Only Orchestrator coordinates between agents. +""" +Matcher agent — cross-references UserProfile against IntelBrief. + +Pipeline: load_profile → compare → score + +Tools the LLM can call during this pipeline: + - get_user_profile: load the UserProfile from DB as JSON + - extract_requirements: parse a lead's context into structured requirements +""" +from __future__ import annotations + +from typing import ClassVar + +from pydantic_ai import Agent, RunContext + +from ingot.agents.base import AgentDeps, AgentRunResult, StepResult +from ingot.agents.registry import register_agent + +_agent: Agent[AgentDeps, str] = Agent( + "ollama:llama3.1", + deps_type=AgentDeps, + defer_model_check=True, + system_prompt=( + "You are a qualification matching agent for INGOT. " + "Cross-reference the user's resume and skills against each lead's opportunity. " + "Produce a 0-100 match score and a tailored value proposition. " + "Use get_user_profile to load the user's background and extract_requirements " + "to parse what the lead is looking for." + ), +) + + +@_agent.tool +async def get_user_profile(ctx: RunContext[AgentDeps]) -> dict: + """Load the UserProfile record from the database as a dict.""" + # Phase 2: query UserProfile table via ctx.deps.session + raise NotImplementedError("Phase 2") + + +@_agent.tool +async def extract_requirements(ctx: RunContext[AgentDeps], lead_context: str) -> dict: + """Parse a lead's job post / company context into structured hiring requirements.""" + # Phase 2: LLM extraction into {role, skills, experience_years, culture_signals} + raise NotImplementedError("Phase 2") + + +class MatcherAgent: + """Scores how well the user's profile matches a lead and generates a value prop.""" + + STEPS: ClassVar[list[str]] = ["load_profile", "compare", "score"] + + async def run( + self, + deps: AgentDeps, + prompt: str = "", + steps: list[str] | None = None, + **kwargs, + ) -> AgentRunResult: + """Execute the full pipeline or a specified subset of steps.""" + targets = steps if steps is not None else self.STEPS + completed: list[StepResult] = [] + for step in targets: + result = await self.run_step(step, deps, **kwargs) + completed.append(result) + if not result.success: + break + return AgentRunResult( + agent_name="matcher", + success=all(r.success for r in completed), + steps=completed, + final_output=completed[-1].output if completed else None, + ) + + async def run_step(self, step: str, deps: AgentDeps, **kwargs) -> StepResult: + """Dispatch a single named step to its implementation method.""" + match step: + case "load_profile": + return await self._load_profile(deps, **kwargs) + case "compare": + return await self._compare(deps, **kwargs) + case "score": + return await self._score(deps, **kwargs) + case _: + raise ValueError(f"Matcher has no step '{step}'. Valid: {self.STEPS}") + + async def _load_profile(self, deps: AgentDeps, **kwargs) -> StepResult: + # Phase 2: hydrate UserProfile from DB + return StepResult(step="load_profile", success=True, output={}) + + async def _compare(self, deps: AgentDeps, **kwargs) -> StepResult: + # Phase 2: LLM compares UserProfile fields against lead requirements + return StepResult(step="compare", success=True, output={}) + + async def _score(self, deps: AgentDeps, **kwargs) -> StepResult: + # Phase 2: produce match_score (0-100) + value_proposition string + return StepResult(step="score", success=True, output={}) + + +matcher = MatcherAgent() +register_agent("matcher", matcher) diff --git a/src/ingot/agents/orchestrator.py b/src/ingot/agents/orchestrator.py new file mode 100644 index 0000000..20b0285 --- /dev/null +++ b/src/ingot/agents/orchestrator.py @@ -0,0 +1,104 @@ +""" +Orchestrator — campaign coordinator and sole agent router. + +AGENT-07: This file must stay under 250 lines. Domain logic belongs in agents. +AGENT-05: Orchestrator is the ONLY module that imports multiple agents. + +Phase 1 skeleton: run() and run_step() delegate to the named agent. +Phase 2 adds: campaign memory, natural language routing, checkpoint logic. +""" +from __future__ import annotations + +from ingot.agents.base import AgentDeps, AgentRunResult, StepResult +from ingot.agents.exceptions import AgentError +from ingot.agents.registry import get_agent, list_agents +from ingot.logging_config import get_logger + +# AGENT-05 exception: Orchestrator imports all agents to ensure they register. +from ingot.agents import ( # noqa: F401 + analyst, + matcher, + outreach, + research, + scout, + writer, +) + +logger = get_logger("ingot.orchestrator") + + +class Orchestrator: + """ + Routes tasks to agents by name. Maintains campaign state (Phase 2). + + Phase 1 skeleton: run() and run_step() delegate directly to the named agent. + Each agent exposes STEPS and run_step() — Orchestrator can execute a full + pipeline or individual steps for checkpointing, retry, and partial runs. + """ + + def __init__(self, deps: AgentDeps) -> None: + self.deps = deps + + async def run( + self, + agent_name: str, + prompt: str = "", + steps: list[str] | None = None, + **kwargs, + ) -> AgentRunResult: + """ + Run a named agent's full pipeline (or a subset of steps). + + Args: + agent_name: Key in AGENT_REGISTRY. + prompt: Optional natural-language instruction passed to the agent. + steps: If provided, only execute these steps (must be subset of agent.STEPS). + + Returns: + AgentRunResult with per-step results and final output. + + Raises: + AgentError: Wraps any exception raised by the agent. + """ + logger.info("dispatching", agent=agent_name, steps=steps) + agent = get_agent(agent_name) + try: + return await agent.run(self.deps, prompt=prompt, steps=steps, **kwargs) + except Exception as exc: + raise AgentError( + "Orchestrator", + f"Agent '{agent_name}' failed: {exc}", + cause=exc, + ) from exc + + async def run_step( + self, agent_name: str, step: str, **kwargs + ) -> StepResult: + """ + Execute a single step on a named agent. + + Used for: checkpointing long pipelines, retrying a failed step, + and Phase 2 conditional routing between steps. + + Raises: + AgentError: Wraps any exception raised by the step. + """ + logger.info("dispatching step", agent=agent_name, step=step) + agent = get_agent(agent_name) + try: + return await agent.run_step(step, self.deps, **kwargs) + except Exception as exc: + raise AgentError( + "Orchestrator", + f"Agent '{agent_name}' step '{step}' failed: {exc}", + cause=exc, + ) from exc + + def list_available_agents(self) -> list[str]: + """Return sorted list of all registered agent names.""" + return list_agents() + + def list_steps(self, agent_name: str) -> list[str]: + """Return the STEPS sequence declared by the named agent.""" + agent = get_agent(agent_name) + return list(agent.STEPS) diff --git a/src/ingot/agents/outreach.py b/src/ingot/agents/outreach.py new file mode 100644 index 0000000..b373970 --- /dev/null +++ b/src/ingot/agents/outreach.py @@ -0,0 +1,113 @@ +# AGENT-05: This module MUST NOT import from other agent modules. +# Only Orchestrator coordinates between agents. +""" +Outreach agent — manages send queue, IMAP reply polling, and follow-up scheduling. + +Pipeline: send → poll_replies → classify_replies → schedule_followups + +Tools the LLM can call during this pipeline: + - classify_reply: classify an IMAP reply as positive/negative/ooo/auto-reply + +Phase 3 wiring: aiosmtplib and aioimaplib are imported here to validate that +the Phase 3 dependencies are installed. Actual SMTP/IMAP usage is implemented +in Phase 3. +""" +from __future__ import annotations + +from typing import ClassVar + +import aiosmtplib # noqa: F401 — Phase 3 dependency validation +import aioimaplib # noqa: F401 — Phase 3 dependency validation +from pydantic_ai import Agent, RunContext + +from ingot.agents.base import AgentDeps, AgentRunResult, StepResult +from ingot.agents.registry import register_agent + +_agent: Agent[AgentDeps, str] = Agent( + "ollama:llama3.1", + deps_type=AgentDeps, + defer_model_check=True, + system_prompt=( + "You are an outreach execution agent for INGOT. " + "Manage email sending (rate limiting, business-hours windows), " + "poll IMAP for replies, classify responses, and schedule follow-ups. " + "Use classify_reply to label each incoming reply." + ), +) + + +@_agent.tool +async def classify_reply(ctx: RunContext[AgentDeps], email_body: str) -> str: + """ + Classify an inbound reply into one of: positive, negative, ooo, auto_reply. + Returns the classification label as a string. + """ + # Phase 3: LLM classification with confidence threshold + raise NotImplementedError("Phase 3") + + +class OutreachAgent: + """Sends emails, polls IMAP, classifies replies, and queues follow-ups.""" + + STEPS: ClassVar[list[str]] = [ + "send", + "poll_replies", + "classify_replies", + "schedule_followups", + ] + + async def run( + self, + deps: AgentDeps, + prompt: str = "", + steps: list[str] | None = None, + **kwargs, + ) -> AgentRunResult: + """Execute the full pipeline or a specified subset of steps.""" + targets = steps if steps is not None else self.STEPS + completed: list[StepResult] = [] + for step in targets: + result = await self.run_step(step, deps, **kwargs) + completed.append(result) + if not result.success: + break + return AgentRunResult( + agent_name="outreach", + success=all(r.success for r in completed), + steps=completed, + final_output=completed[-1].output if completed else None, + ) + + async def run_step(self, step: str, deps: AgentDeps, **kwargs) -> StepResult: + """Dispatch a single named step to its implementation method.""" + match step: + case "send": + return await self._send(deps, **kwargs) + case "poll_replies": + return await self._poll_replies(deps, **kwargs) + case "classify_replies": + return await self._classify_replies(deps, **kwargs) + case "schedule_followups": + return await self._schedule_followups(deps, **kwargs) + case _: + raise ValueError(f"Outreach has no step '{step}'. Valid: {self.STEPS}") + + async def _send(self, deps: AgentDeps, **kwargs) -> StepResult: + # Phase 3: aiosmtplib send with rate limiting and business-hours gating + return StepResult(step="send", success=True, output={}) + + async def _poll_replies(self, deps: AgentDeps, **kwargs) -> StepResult: + # Phase 3: aioimaplib IDLE or polling for new messages + return StepResult(step="poll_replies", success=True, output={}) + + async def _classify_replies(self, deps: AgentDeps, **kwargs) -> StepResult: + # Phase 3: run classify_reply tool on each new message + return StepResult(step="classify_replies", success=True, output={}) + + async def _schedule_followups(self, deps: AgentDeps, **kwargs) -> StepResult: + # Phase 3: APScheduler jobs for Day-3 and Day-7 follow-ups + return StepResult(step="schedule_followups", success=True, output={}) + + +outreach = OutreachAgent() +register_agent("outreach", outreach) diff --git a/src/ingot/agents/registry.py b/src/ingot/agents/registry.py new file mode 100644 index 0000000..f336c17 --- /dev/null +++ b/src/ingot/agents/registry.py @@ -0,0 +1,31 @@ +""" +Agent registry — dict[str, Any] mapping agent names to PydanticAI Agent instances. + +In v2, this becomes dynamic discovery via entry points or a plugin dir scan. +In v1, agents register explicitly by calling register_agent() at import time. +""" +from __future__ import annotations + +from typing import Any + +AGENT_REGISTRY: dict[str, Any] = {} + + +def register_agent(name: str, agent: Any) -> None: + """Register an agent by name. Called from each agent module at import time.""" + AGENT_REGISTRY[name] = agent + + +def get_agent(name: str) -> Any: + """Retrieve agent by name. Raises KeyError if not registered.""" + if name not in AGENT_REGISTRY: + registered = list(AGENT_REGISTRY.keys()) + raise KeyError( + f"Agent '{name}' not in registry. Registered: {registered}" + ) + return AGENT_REGISTRY[name] + + +def list_agents() -> list[str]: + """Return sorted list of registered agent names.""" + return sorted(AGENT_REGISTRY.keys()) diff --git a/src/ingot/agents/research.py b/src/ingot/agents/research.py new file mode 100644 index 0000000..2690d21 --- /dev/null +++ b/src/ingot/agents/research.py @@ -0,0 +1,112 @@ +# AGENT-05: This module MUST NOT import from other agent modules. +# Only Orchestrator coordinates between agents. +""" +Research agent — builds deep IntelBrief per lead. + +Pipeline: fetch_company → fetch_person → identify_signals → synthesise + +Tools the LLM can call during this pipeline: + - search_web: run a web search query, returns list of result snippets + - fetch_page: HTTP GET an arbitrary URL, returns text content +""" +from __future__ import annotations + +from typing import ClassVar + +from pydantic_ai import Agent, RunContext + +from ingot.agents.base import AgentDeps, AgentRunResult, StepResult +from ingot.agents.registry import register_agent + +_agent: Agent[AgentDeps, str] = Agent( + "ollama:llama3.1", + deps_type=AgentDeps, + defer_model_check=True, + system_prompt=( + "You are a deep research agent for INGOT. " + "Build comprehensive IntelBriefs per lead: company intelligence, " + "person intelligence, recent signals, and personalized talking points. " + "Use search_web and fetch_page to gather current information." + ), +) + + +@_agent.tool +async def search_web(ctx: RunContext[AgentDeps], query: str) -> list[str]: + """Run a web search and return a list of result snippets.""" + # Phase 2: DuckDuckGo or SerpAPI via http_client + raise NotImplementedError("Phase 2") + + +@_agent.tool +async def fetch_page(ctx: RunContext[AgentDeps], url: str) -> str: + """Fetch and return the text content of a web page.""" + # Phase 2: httpx GET with optional Playwright fallback for SPAs + raise NotImplementedError("Phase 2") + + +class ResearchAgent: + """Builds IntelBrief for a single lead: company, person, signals, talking points.""" + + STEPS: ClassVar[list[str]] = [ + "fetch_company", + "fetch_person", + "identify_signals", + "synthesise", + ] + + async def run( + self, + deps: AgentDeps, + prompt: str = "", + steps: list[str] | None = None, + **kwargs, + ) -> AgentRunResult: + """Execute the full pipeline or a specified subset of steps.""" + targets = steps if steps is not None else self.STEPS + completed: list[StepResult] = [] + for step in targets: + result = await self.run_step(step, deps, **kwargs) + completed.append(result) + if not result.success: + break + return AgentRunResult( + agent_name="research", + success=all(r.success for r in completed), + steps=completed, + final_output=completed[-1].output if completed else None, + ) + + async def run_step(self, step: str, deps: AgentDeps, **kwargs) -> StepResult: + """Dispatch a single named step to its implementation method.""" + match step: + case "fetch_company": + return await self._fetch_company(deps, **kwargs) + case "fetch_person": + return await self._fetch_person(deps, **kwargs) + case "identify_signals": + return await self._identify_signals(deps, **kwargs) + case "synthesise": + return await self._synthesise(deps, **kwargs) + case _: + raise ValueError(f"Research has no step '{step}'. Valid: {self.STEPS}") + + async def _fetch_company(self, deps: AgentDeps, **kwargs) -> StepResult: + # Phase 2: search + fetch company page → structured company intel + return StepResult(step="fetch_company", success=True, output={}) + + async def _fetch_person(self, deps: AgentDeps, **kwargs) -> StepResult: + # Phase 2: search LinkedIn/Twitter/GitHub for target person + return StepResult(step="fetch_person", success=True, output={}) + + async def _identify_signals(self, deps: AgentDeps, **kwargs) -> StepResult: + # Phase 2: recent funding, hiring posts, blog posts, GitHub activity + return StepResult(step="identify_signals", success=True, output={}) + + async def _synthesise(self, deps: AgentDeps, **kwargs) -> StepResult: + # Phase 2: LLM synthesises company + person + signals → IntelBrief + talking points + return StepResult(step="synthesise", success=True, output={}) + + +research = ResearchAgent() +register_agent("research", research) diff --git a/src/ingot/agents/scout.py b/src/ingot/agents/scout.py new file mode 100644 index 0000000..eb7b415 --- /dev/null +++ b/src/ingot/agents/scout.py @@ -0,0 +1,101 @@ +# AGENT-05: This module MUST NOT import from other agent modules. +# Only Orchestrator coordinates between agents. +""" +Scout agent — discovers and qualifies startup leads from configured venues. + +Pipeline: discover → deduplicate → score + +Tools the LLM can call during this pipeline: + - fetch_venue_page: HTTP GET a venue listing (YC, etc.) + - extract_company_list: parse HTML into structured company entries +""" +from __future__ import annotations + +from typing import ClassVar + +from pydantic_ai import Agent, RunContext + +from ingot.agents.base import AgentDeps, AgentRunResult, StepResult +from ingot.agents.registry import register_agent + +# Module-level PydanticAI Agent — tools must be registered here (not inside the class). +_agent: Agent[AgentDeps, str] = Agent( + "ollama:llama3.1", + deps_type=AgentDeps, + defer_model_check=True, + system_prompt=( + "You are a lead discovery agent for INGOT. " + "You discover and qualify startup leads from venues for personalized outreach. " + "Use the available tools to fetch venue pages and extract company listings." + ), +) + + +@_agent.tool +async def fetch_venue_page(ctx: RunContext[AgentDeps], url: str) -> str: + """Fetch a venue listing page (e.g. YC batch page). Returns raw HTML.""" + # Phase 2: real HTTP fetch with rate limiting + raise NotImplementedError("Phase 2") + + +@_agent.tool +async def extract_company_list(ctx: RunContext[AgentDeps], html: str) -> list[str]: + """Parse a venue page HTML into a list of company name strings.""" + # Phase 2: CSS-selector or LLM-based extraction + raise NotImplementedError("Phase 2") + + +class ScoutAgent: + """Discovers leads, deduplicates against DB, and applies initial scoring.""" + + STEPS: ClassVar[list[str]] = ["discover", "deduplicate", "score"] + + async def run( + self, + deps: AgentDeps, + prompt: str = "", + steps: list[str] | None = None, + **kwargs, + ) -> AgentRunResult: + """Execute the full pipeline or a specified subset of steps.""" + targets = steps if steps is not None else self.STEPS + completed: list[StepResult] = [] + for step in targets: + result = await self.run_step(step, deps, **kwargs) + completed.append(result) + if not result.success: + break + return AgentRunResult( + agent_name="scout", + success=all(r.success for r in completed), + steps=completed, + final_output=completed[-1].output if completed else None, + ) + + async def run_step(self, step: str, deps: AgentDeps, **kwargs) -> StepResult: + """Dispatch a single named step to its implementation method.""" + match step: + case "discover": + return await self._discover(deps, **kwargs) + case "deduplicate": + return await self._deduplicate(deps, **kwargs) + case "score": + return await self._score(deps, **kwargs) + case _: + raise ValueError(f"Scout has no step '{step}'. Valid: {self.STEPS}") + + async def _discover(self, deps: AgentDeps, **kwargs) -> StepResult: + # Phase 2: call _agent.run() with fetch_venue_page + extract_company_list tools + return StepResult(step="discover", success=True, output={}) + + async def _deduplicate(self, deps: AgentDeps, **kwargs) -> StepResult: + # Phase 2: cross-reference discovered companies against leads already in DB + return StepResult(step="deduplicate", success=True, output={}) + + async def _score(self, deps: AgentDeps, **kwargs) -> StepResult: + # Phase 2: apply initial qualification score (funding stage, team size, role fit) + return StepResult(step="score", success=True, output={}) + + +scout = ScoutAgent() +register_agent("scout", scout) diff --git a/src/ingot/agents/writer.py b/src/ingot/agents/writer.py new file mode 100644 index 0000000..1683133 --- /dev/null +++ b/src/ingot/agents/writer.py @@ -0,0 +1,105 @@ +# AGENT-05: This module MUST NOT import from other agent modules. +# Only Orchestrator coordinates between agents. +""" +Writer agent — composes personalized cold emails from Lead + IntelBrief + ValueProp. + +Pipeline: draft → generate_subjects → draft_followups + +Tools the LLM can call during this pipeline: + - load_intel_brief: fetch IntelBrief record from DB for a given lead + - get_tone_guide: return tone and style instructions for a recipient role +""" +from __future__ import annotations + +from typing import ClassVar + +from pydantic_ai import Agent, RunContext + +from ingot.agents.base import AgentDeps, AgentRunResult, StepResult +from ingot.agents.registry import register_agent + +_agent: Agent[AgentDeps, str] = Agent( + "ollama:llama3.1", + deps_type=AgentDeps, + defer_model_check=True, + system_prompt=( + "You are an email composition agent for INGOT. " + "Write highly personalized cold outreach emails using the lead's IntelBrief " + "and the user's matched value proposition. " + "Use load_intel_brief to retrieve research and get_tone_guide to adapt style. " + "Produce 2 subject line variants and Day-3 and Day-7 follow-up drafts." + ), +) + + +@_agent.tool +async def load_intel_brief(ctx: RunContext[AgentDeps], lead_id: int) -> dict: + """Fetch the IntelBrief for a lead from the database.""" + # Phase 2: query IntelBrief table via ctx.deps.session + raise NotImplementedError("Phase 2") + + +@_agent.tool +async def get_tone_guide(ctx: RunContext[AgentDeps], recipient_role: str) -> str: + """ + Return tone and length guidance for a given recipient role. + recipient_role: one of 'ceo', 'cto', 'recruiter', 'hiring_manager', 'engineer' + """ + # Phase 2: role → tone template (formal/casual, short/long, CTA style) + raise NotImplementedError("Phase 2") + + +class WriterAgent: + """Drafts email body, 2 subject variants, and Day-3/Day-7 follow-up sequences.""" + + STEPS: ClassVar[list[str]] = ["draft", "generate_subjects", "draft_followups"] + + async def run( + self, + deps: AgentDeps, + prompt: str = "", + steps: list[str] | None = None, + **kwargs, + ) -> AgentRunResult: + """Execute the full pipeline or a specified subset of steps.""" + targets = steps if steps is not None else self.STEPS + completed: list[StepResult] = [] + for step in targets: + result = await self.run_step(step, deps, **kwargs) + completed.append(result) + if not result.success: + break + return AgentRunResult( + agent_name="writer", + success=all(r.success for r in completed), + steps=completed, + final_output=completed[-1].output if completed else None, + ) + + async def run_step(self, step: str, deps: AgentDeps, **kwargs) -> StepResult: + """Dispatch a single named step to its implementation method.""" + match step: + case "draft": + return await self._draft(deps, **kwargs) + case "generate_subjects": + return await self._generate_subjects(deps, **kwargs) + case "draft_followups": + return await self._draft_followups(deps, **kwargs) + case _: + raise ValueError(f"Writer has no step '{step}'. Valid: {self.STEPS}") + + async def _draft(self, deps: AgentDeps, **kwargs) -> StepResult: + # Phase 2: LLM drafts email body using IntelBrief + ValueProp + tone guide + return StepResult(step="draft", success=True, output={}) + + async def _generate_subjects(self, deps: AgentDeps, **kwargs) -> StepResult: + # Phase 2: generate 2 subject line variants for A/B testing + return StepResult(step="generate_subjects", success=True, output={}) + + async def _draft_followups(self, deps: AgentDeps, **kwargs) -> StepResult: + # Phase 2: draft Day-3 and Day-7 follow-up emails for non-replies + return StepResult(step="draft_followups", success=True, output={}) + + +writer = WriterAgent() +register_agent("writer", writer) diff --git a/src/ingot/cli/__init__.py b/src/ingot/cli/__init__.py new file mode 100644 index 0000000..57481c4 --- /dev/null +++ b/src/ingot/cli/__init__.py @@ -0,0 +1,22 @@ +"""CLI entry point for INGOT.""" +import typer + +from ingot.cli.setup import setup_app + +# Use invoke_without_command=True so that the app always shows the Commands +# section even with a single sub-command registered. +app = typer.Typer( + name="ingot", + help="INGOT — INtelligent Generation & Outreach Tool", + no_args_is_help=True, +) + + +@app.callback(invoke_without_command=True) +def main(ctx: typer.Context) -> None: + """INGOT — INtelligent Generation & Outreach Tool.""" + if ctx.invoked_subcommand is None: + typer.echo(ctx.get_help()) + + +app.command(name="setup", help="Run the INGOT setup wizard")(setup_app) diff --git a/src/ingot/cli/setup.py b/src/ingot/cli/setup.py new file mode 100644 index 0000000..276e976 --- /dev/null +++ b/src/ingot/cli/setup.py @@ -0,0 +1,331 @@ +"""Setup wizard CLI command for INGOT. + +Supports both interactive (questionary prompts) and non-interactive +(env vars + --preset flag) modes. Existing values in config.json are +skipped — only missing or blank fields are prompted. + +Usage: + ingot setup # interactive + ingot setup --preset fully_free # interactive, apply preset + ingot setup --non-interactive --preset best_quality # CI/env-var mode +""" +from __future__ import annotations + +import logging +import os +import sys +import traceback +from pathlib import Path + +import questionary +import typer +from rich.console import Console +from rich.table import Table + +from ingot.config.manager import ConfigManager +from ingot.config.schema import AgentConfig, AppConfig +from ingot.logging_config import configure_logging + +# ----- constants ---- + +_AGENT_NAMES: list[str] = [ + "orchestrator", + "scout", + "research", + "matcher", + "writer", + "outreach", + "analyst", +] + +_PRESET_FULLY_FREE = "fully_free" +_PRESET_BEST_QUALITY = "best_quality" + +_OLLAMA_MODEL = "ollama/llama3.1" +_CLAUDE_SONNET = "anthropic/claude-3-5-sonnet-20241022" +_CLAUDE_HAIKU = "anthropic/claude-3-haiku-20240307" + +_PRESET_MODELS: dict[str, dict[str, str]] = { + _PRESET_FULLY_FREE: {name: _OLLAMA_MODEL for name in _AGENT_NAMES}, + _PRESET_BEST_QUALITY: { + **{name: _CLAUDE_HAIKU for name in _AGENT_NAMES}, + "writer": _CLAUDE_SONNET, + "research": _CLAUDE_SONNET, + }, +} + +_out = Console() +_err = Console(stderr=True) + + +# ----- helpers ------ + +def _mask(value: str, show_chars: int = 4) -> str: + """Return a masked version of a secret for display.""" + if not value: + return "(not set)" + if len(value) <= show_chars: + return "*" * len(value) + return value[:show_chars] + "*" * (len(value) - show_chars) + + +def _apply_preset(cfg: AppConfig, preset_name: str) -> None: + """Apply a named preset to the agent model map.""" + models = _PRESET_MODELS.get(preset_name) + if models is None: + _err.print(f"[red]Unknown preset '{preset_name}'. Choose 'fully_free' or 'best_quality'.[/red]") + raise typer.Exit(code=1) + for agent_name, model in models.items(): + if agent_name not in cfg.agents: + cfg.agents[agent_name] = AgentConfig(model=model) + else: + cfg.agents[agent_name].model = model + + +def _print_summary(cfg: AppConfig, cm: ConfigManager) -> None: + """Print a Rich summary table of configured services.""" + log_dir = cm.base_dir / "logs" + + table = Table(title="INGOT Configuration Summary", show_lines=True) + table.add_column("Service", style="bold cyan") + table.add_column("Status") + table.add_column("Value") + + def status(val: str) -> str: + return "[green]configured[/green]" if val else "[yellow]not set[/yellow]" + + table.add_row("Gmail Username", status(cfg.smtp.username), cfg.smtp.username or "(not set)") + table.add_row("Gmail App Password", status(cfg.smtp.password), _mask(cfg.smtp.password)) + table.add_row("Anthropic API Key", status(cfg.anthropic_api_key), _mask(cfg.anthropic_api_key)) + table.add_row("OpenAI API Key", status(cfg.openai_api_key), _mask(cfg.openai_api_key)) + table.add_row("Mailing Address", status(cfg.mailing_address), cfg.mailing_address or "(not set)") + + for agent in _AGENT_NAMES: + model = cfg.agents.get(agent, AgentConfig()).model + table.add_row(f"Agent: {agent}", "[green]configured[/green]", model) + + _out.print(table) + _out.print(f"\n[dim]Log directory: {log_dir}[/dim]") + + +# ----- main command ------ + +def setup_app( + non_interactive: bool = typer.Option( + False, "--non-interactive", help="Read credentials from env vars" + ), + preset: str | None = typer.Option( + None, "--preset", help="'fully_free' or 'best_quality'" + ), + verbose: int = typer.Option(0, "-v", count=True, max=2), +) -> None: + """Run the INGOT setup wizard to configure credentials and LLM backends.""" + try: + _run_setup(non_interactive=non_interactive, preset=preset, verbose=verbose) + except KeyboardInterrupt as exc: + _err.print("\n[yellow]Setup cancelled.[/yellow]") + raise typer.Exit(code=1) from exc + except typer.Exit: + raise + except Exception as exc: + log_path = Path.home() / ".ingot" / "logs" + _err.print(f"[red][Setup] Something went wrong. Full error logged to {log_path}[/red]") + logging.getLogger("ingot.cli.setup").error( + "Setup wizard failed", exc_info=True + ) + raise typer.Exit(code=1) from exc + + +def _run_setup( + *, + non_interactive: bool, + preset: str | None, + verbose: int, +) -> None: + """Internal setup logic separated from the Typer decorator.""" + cm = ConfigManager() + cm.ensure_dirs() + configure_logging(cm.base_dir, verbosity=verbose) + cfg = cm.load() + + if non_interactive: + _run_non_interactive(cfg, preset=preset) + else: + _run_interactive(cfg, preset=preset) + + cm.save(cfg) + _out.print("\n[green]Setup complete![/green]") + _print_summary(cfg, cm) + + +# ----- non-interactive mode ------ + +def _run_non_interactive(cfg: AppConfig, preset: str | None) -> None: + """Populate config from environment variables.""" + gmail_username = os.environ.get("GMAIL_USERNAME", "") + gmail_password = os.environ.get("GMAIL_APP_PASSWORD", "") + anthropic_key = os.environ.get("ANTHROPIC_API_KEY", "") + openai_key = os.environ.get("OPENAI_API_KEY", "") + + if gmail_username: + cfg.smtp.username = gmail_username + cfg.imap.username = gmail_username + if gmail_password: + cfg.smtp.password = gmail_password + cfg.imap.password = gmail_password + if anthropic_key: + cfg.anthropic_api_key = anthropic_key + if openai_key: + cfg.openai_api_key = openai_key + + # Apply preset or default to fully_free + effective_preset = preset or _PRESET_FULLY_FREE + _apply_preset(cfg, effective_preset) + + # Ensure all 7 agents exist + for agent_name in _AGENT_NAMES: + if agent_name not in cfg.agents: + cfg.agents[agent_name] = AgentConfig() + + # Validate required API keys based on configured models + errors: list[str] = [] + needs_anthropic = any("anthropic" in a.model for a in cfg.agents.values()) + needs_openai = any("openai" in a.model or "gpt" in a.model for a in cfg.agents.values()) + if needs_anthropic and not cfg.anthropic_api_key: + errors.append( + "ANTHROPIC_API_KEY is required for the selected preset/models but was not set." + ) + if needs_openai and not cfg.openai_api_key: + errors.append( + "OPENAI_API_KEY is required for the selected preset/models but was not set." + ) + if gmail_username and not cfg.smtp.password: + errors.append( + "GMAIL_APP_PASSWORD must be set when GMAIL_USERNAME is provided." + ) + + if errors: + for error in errors: + _err.print(f"[red]{error}[/red]") + raise typer.Exit(code=1) + + +# ----- interactive mode ------ + +def _run_interactive(cfg: AppConfig, preset: str | None) -> None: + """Prompt user for each unconfigured value.""" + _out.print("[bold]INGOT Setup Wizard[/bold]\n") + + # Step 1: Gmail username + if not cfg.smtp.username: + username = questionary.text("Gmail address for sending:").ask() + if username: + cfg.smtp.username = username.strip() + cfg.imap.username = username.strip() + else: + _out.print(f"Gmail address: [dim][already configured: {cfg.smtp.username}][/dim]") + + # Step 2: Gmail App Password + if not cfg.smtp.password: + password = questionary.password("Gmail App Password:").ask() + if not password: + _err.print("[red]Gmail App Password cannot be empty.[/red]") + raise typer.Exit(code=1) + cfg.smtp.password = password + cfg.imap.password = password + else: + _out.print("Gmail App Password: [dim][already configured][/dim]") + + # Step 3: Mailing address (CAN-SPAM) + if not cfg.mailing_address: + address = questionary.text( + "Physical mailing address (required for CAN-SPAM):" + ).ask() + if address: + cfg.mailing_address = address.strip() + else: + _out.print("Mailing address: [dim][already configured][/dim]") + + # Step 4: Determine LLM preset or ask + effective_preset = preset + if effective_preset is None: + choice = questionary.select( + "LLM setup:", + choices=[ + "fully_free (all Ollama)", + "best_quality (Claude Sonnet for Writer+Research, Haiku for rest)", + "custom", + ], + ).ask() + + if choice and choice.startswith("fully_free"): + effective_preset = _PRESET_FULLY_FREE + elif choice and choice.startswith("best_quality"): + effective_preset = _PRESET_BEST_QUALITY + else: + effective_preset = "custom" + + # Step 5: Apply preset or prompt per-agent + if effective_preset in (_PRESET_FULLY_FREE, _PRESET_BEST_QUALITY): + _apply_preset(cfg, effective_preset) + else: + _prompt_custom_agents(cfg) + + # Step 6: API keys based on which models are in use + needs_anthropic = any( + "anthropic" in a.model for a in cfg.agents.values() + ) + needs_openai = any( + "openai" in a.model or "gpt" in a.model for a in cfg.agents.values() + ) + needs_ollama = any( + "ollama" in a.model for a in cfg.agents.values() + ) + + if needs_anthropic and not cfg.anthropic_api_key: + while True: + key = questionary.password("Anthropic API Key (sk-ant-...):").ask() + if not key: + _err.print("[red]Anthropic API key is required. Press Ctrl+C to abort.[/red]") + continue + if not key.startswith("sk-ant-"): + _err.print("[yellow]Warning: key does not start with 'sk-ant-'[/yellow]") + cfg.anthropic_api_key = key + break + + if needs_openai and not cfg.openai_api_key: + while True: + key = questionary.password("OpenAI API Key (sk-...):").ask() + if not key: + _err.print("[red]OpenAI API key is required. Press Ctrl+C to abort.[/red]") + continue + if not key.startswith("sk-"): + _err.print("[yellow]Warning: key does not start with 'sk-'[/yellow]") + cfg.openai_api_key = key + break + + if needs_ollama: + _out.print( + "[dim]Note: Ensure Ollama is running at localhost:11434[/dim]" + ) + + # Ensure all 7 agents exist + for agent_name in _AGENT_NAMES: + if agent_name not in cfg.agents: + cfg.agents[agent_name] = AgentConfig() + + +def _prompt_custom_agents(cfg: AppConfig) -> None: + """Prompt for a model string for each of the 7 agents.""" + _out.print("\n[bold]Custom agent model configuration:[/bold]") + for agent_name in _AGENT_NAMES: + current = cfg.agents.get(agent_name, AgentConfig()).model + model = questionary.text( + f"Model for {agent_name}:", + default=current, + ).ask() + if model: + if agent_name not in cfg.agents: + cfg.agents[agent_name] = AgentConfig(model=model.strip()) + else: + cfg.agents[agent_name].model = model.strip() diff --git a/src/ingot/config/__init__.py b/src/ingot/config/__init__.py new file mode 100644 index 0000000..07e3461 --- /dev/null +++ b/src/ingot/config/__init__.py @@ -0,0 +1 @@ +"""Configuration subsystem for INGOT.""" diff --git a/src/ingot/config/crypto.py b/src/ingot/config/crypto.py new file mode 100644 index 0000000..d1dd5ef --- /dev/null +++ b/src/ingot/config/crypto.py @@ -0,0 +1,112 @@ +"""Fernet encryption for INGOT config secrets. + +Uses PBKDF2HMAC to derive a Fernet key from a machine-generated random key +stored at ~/.ingot/.key. The machine key has full entropy so +600_000 PBKDF2 iterations are sufficient (not 1,200,000 which is for +low-entropy passwords). + +Pattern: KEY_FILE holds 32 random bytes (os.urandom). PBKDF2HMAC derives +a deterministic Fernet key from those bytes using a static salt. +""" +from __future__ import annotations + +import base64 +import os +from pathlib import Path + +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +from ingot.agents.exceptions import ConfigError # noqa: F401 — re-exported for callers + +# Location of the machine-specific random key file +KEY_FILE: Path = Path.home() / ".ingot" / ".key" + +# Static salt — unique per application, not per user +SALT: bytes = b"ingot-v1-static-salt" + +# PBKDF2 iteration count — machine key has full entropy, so 600k is sufficient +_PBKDF2_ITERATIONS: int = 600_000 + + +def _load_or_create_machine_key() -> bytes: + """Load the machine key from KEY_FILE, creating it if it does not exist. + + On first run: + - Creates the parent directory (~/.ingot/) if needed. + - Generates 32 cryptographically random bytes via os.urandom(32). + - Writes the key file with chmod 0o600 (owner read/write only). + + Returns: + 32 random bytes used as the master secret for Fernet key derivation. + """ + KEY_FILE.parent.mkdir(parents=True, exist_ok=True) + + if KEY_FILE.exists(): + return KEY_FILE.read_bytes() + + # Generate and persist a fresh machine key with atomic 0o600 permissions. + # O_EXCL + mode=0o600 ensures the file is created with restricted permissions + # from the start, eliminating the TOCTOU window that write_bytes + chmod has. + key_bytes = os.urandom(32) + fd = os.open(str(KEY_FILE), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "wb") as f: + f.write(key_bytes) + return key_bytes + + +def get_fernet() -> Fernet: + """Derive a Fernet instance from the machine key via PBKDF2HMAC. + + The derivation is deterministic: the same machine key always produces + the same Fernet key, so previously encrypted secrets can always be + decrypted on the same machine. + + Returns: + A ready-to-use Fernet instance. + """ + machine_key = _load_or_create_machine_key() + + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=SALT, + iterations=_PBKDF2_ITERATIONS, + ) + fernet_key = base64.urlsafe_b64encode(kdf.derive(machine_key)) + return Fernet(fernet_key) + + +def encrypt_secret(plaintext: str) -> str: + """Encrypt a plaintext string using the machine Fernet key. + + Args: + plaintext: The secret value to encrypt (e.g., an API key or password). + + Returns: + A base64-encoded ciphertext string (safe to store in config.json). + """ + fernet = get_fernet() + ciphertext_bytes = fernet.encrypt(plaintext.encode("utf-8")) + return ciphertext_bytes.decode("utf-8") + + +def decrypt_secret(ciphertext: str) -> str: + """Decrypt a ciphertext string produced by encrypt_secret(). + + Args: + ciphertext: The base64-encoded ciphertext string from encrypt_secret(). + + Returns: + The original plaintext string. + + Raises: + ConfigError: If decryption fails (wrong key, corrupted data, etc.). + """ + fernet = get_fernet() + try: + plaintext_bytes = fernet.decrypt(ciphertext.encode("utf-8")) + return plaintext_bytes.decode("utf-8") + except Exception as exc: + raise ConfigError(f"Failed to decrypt secret: {exc}") from exc diff --git a/src/ingot/config/manager.py b/src/ingot/config/manager.py new file mode 100644 index 0000000..12e927a --- /dev/null +++ b/src/ingot/config/manager.py @@ -0,0 +1,157 @@ +"""ConfigManager: read/write config.json with Fernet-encrypted secrets. + +Secrets are stored with a "__encrypted__:" prefix so the manager knows +which fields to decrypt on load. Example on disk: + + { + "smtp": { + "password": "__encrypted__:gAAAAABh..." + } + } +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from ingot.config.crypto import decrypt_secret, encrypt_secret +from ingot.config.schema import AppConfig + +# Fields whose values must be encrypted at rest. +# Format: list of dot-separated key paths into the serialized JSON dict. +_SECRET_FIELDS: list[str] = [ + "smtp.password", + "imap.password", + "anthropic_api_key", + "openai_api_key", +] + +_ENCRYPTED_PREFIX: str = "__encrypted__:" + + +class ConfigManager: + """Manages loading and saving INGOT's config.json. + + Usage:: + + cm = ConfigManager() # uses ~/.ingot/ + cfg = cm.load() # returns AppConfig (decrypts secrets) + cfg.smtp.password = "secret" + cm.save(cfg) # encrypts secrets, writes atomically + """ + + def __init__(self, base_dir: Path | None = None) -> None: + self.base_dir: Path = base_dir or Path.home() / ".ingot" + self.config_path: Path = self.base_dir / "config.json" + + # ------------------------------------------------------------------ + # Directory management + # ------------------------------------------------------------------ + + def ensure_dirs(self) -> None: + """Create ~/.ingot/ and required subdirectories. + + Called on first run before saving config. Safe to call repeatedly + (uses exist_ok=True). + """ + for subdir in ["", "logs", "resume", "venues"]: + (self.base_dir / subdir if subdir else self.base_dir).mkdir( + parents=True, exist_ok=True + ) + + # ------------------------------------------------------------------ + # Load / save + # ------------------------------------------------------------------ + + def load(self) -> AppConfig: + """Load and parse config.json. + + Returns a default AppConfig if the file does not exist. + Secret fields carrying the __encrypted__: prefix are decrypted + in-memory before returning. + """ + if not self.config_path.exists(): + return AppConfig() + + raw = json.loads(self.config_path.read_text(encoding="utf-8")) + self._decrypt_in_place(raw) + return AppConfig.model_validate(raw) + + def save(self, config: AppConfig) -> None: + """Encrypt secret fields and write config.json atomically. + + The write is atomic: data is first written to a .tmp file, + then renamed over the real config.json. This prevents partial + writes from corrupting the config. + + Args: + config: The AppConfig instance to persist. + """ + self.ensure_dirs() + raw = config.model_dump() + self._encrypt_in_place(raw) + + # Atomic write: write to .tmp, then rename + tmp_path = self.config_path.with_suffix(".tmp") + tmp_path.write_text( + json.dumps(raw, indent=2, ensure_ascii=False), encoding="utf-8" + ) + tmp_path.replace(self.config_path) + + # ------------------------------------------------------------------ + # Helper: DB path + # ------------------------------------------------------------------ + + def get_db_path(self) -> Path: + """Return the canonical SQLite database path.""" + return self.base_dir / "outreach.db" + + # ------------------------------------------------------------------ + # Internal: encrypt/decrypt helpers + # ------------------------------------------------------------------ + + def _encrypt_in_place(self, raw: dict[str, Any]) -> None: + """Encrypt all secret fields in the raw dict (mutates in place).""" + for field_path in _SECRET_FIELDS: + self._set_encrypted(raw, field_path) + + def _decrypt_in_place(self, raw: dict[str, Any]) -> None: + """Decrypt all secret fields in the raw dict (mutates in place).""" + for field_path in _SECRET_FIELDS: + self._set_decrypted(raw, field_path) + + def _set_encrypted(self, raw: dict[str, Any], field_path: str) -> None: + """Encrypt a single field given its dot-separated path.""" + keys = field_path.split(".") + obj = raw + for key in keys[:-1]: + if not isinstance(obj, dict) or key not in obj: + return + obj = obj[key] + + leaf = keys[-1] + if not isinstance(obj, dict) or leaf not in obj: + return + + value = obj[leaf] + if isinstance(value, str) and value and not value.startswith(_ENCRYPTED_PREFIX): + obj[leaf] = _ENCRYPTED_PREFIX + encrypt_secret(value) + + def _set_decrypted(self, raw: dict[str, Any], field_path: str) -> None: + """Decrypt a single field given its dot-separated path.""" + keys = field_path.split(".") + obj = raw + for key in keys[:-1]: + if not isinstance(obj, dict) or key not in obj: + return + obj = obj[key] + + leaf = keys[-1] + if not isinstance(obj, dict) or leaf not in obj: + return + + value = obj[leaf] + if isinstance(value, str) and value.startswith(_ENCRYPTED_PREFIX): + ciphertext = value[len(_ENCRYPTED_PREFIX):] + obj[leaf] = decrypt_secret(ciphertext) diff --git a/src/ingot/config/schema.py b/src/ingot/config/schema.py new file mode 100644 index 0000000..58cc051 --- /dev/null +++ b/src/ingot/config/schema.py @@ -0,0 +1,84 @@ +"""Pydantic v2 models for INGOT's config.json structure. + +These are plain BaseModel instances (not SQLModel table=True) — they represent +the application configuration, not the database schema. +""" +from __future__ import annotations + +from pydantic import BaseModel, Field + +# Default agent names used throughout the system +_DEFAULT_AGENT_NAMES: list[str] = [ + "orchestrator", + "scout", + "research", + "matcher", + "writer", + "outreach", + "analyst", +] + + +class AgentConfig(BaseModel): + """Per-agent LLM backend configuration.""" + + model: str = "ollama/llama3.1" + """LiteLLM model string, e.g. 'ollama/llama3.1' or 'anthropic/claude-3-5-sonnet-20241022'.""" + + +class SmtpConfig(BaseModel): + """SMTP connection settings for sending emails.""" + + host: str = "smtp.gmail.com" + port: int = 587 + username: str = "" + password: str = "" + """Fernet-encrypted when stored on disk. Plaintext in memory.""" + + +class ImapConfig(BaseModel): + """IMAP connection settings for reading/polling reply emails.""" + + host: str = "imap.gmail.com" + port: int = 993 + username: str = "" + password: str = "" + """Fernet-encrypted when stored on disk. Plaintext in memory.""" + + +def _default_agents() -> dict[str, AgentConfig]: + """Build the default agent config dict with all 7 agents.""" + return {name: AgentConfig() for name in _DEFAULT_AGENT_NAMES} + + +class AppConfig(BaseModel): + """Root configuration model for INGOT. + + Serialized to ~/.ingot/config.json. Secret fields (smtp.password, + imap.password, and any api_key fields) are Fernet-encrypted before writing + and decrypted after reading by ConfigManager. + """ + + agents: dict[str, AgentConfig] = Field(default_factory=_default_agents) + """Map of agent name → AgentConfig. Defaults include all 7 core agents.""" + + smtp: SmtpConfig = Field(default_factory=SmtpConfig) + imap: ImapConfig = Field(default_factory=ImapConfig) + + max_retries: int = 3 + backoff_strategy: str = "exponential" + llm_fallback_chain: list[str] = Field( + default_factory=lambda: ["claude", "openai", "ollama"] + ) + + db_path: str = "" + log_dir: str = "" + resume_dir: str = "" + venues_dir: str = "" + + # CAN-SPAM compliance — physical mailing address required in footer + mailing_address: str = "" + + # API keys (Fernet-encrypted on disk) + anthropic_api_key: str = "" + openai_api_key: str = "" diff --git a/src/ingot/db/__init__.py b/src/ingot/db/__init__.py new file mode 100644 index 0000000..6af8d62 --- /dev/null +++ b/src/ingot/db/__init__.py @@ -0,0 +1,5 @@ +"""Database package: engine, models, and repositories.""" +from ingot.db.engine import AsyncSessionLocal, engine, get_session, init_db +from ingot.db.models import ContactType, LeadContact + +__all__ = ["engine", "AsyncSessionLocal", "get_session", "init_db", "ContactType", "LeadContact"] diff --git a/src/ingot/db/engine.py b/src/ingot/db/engine.py new file mode 100644 index 0000000..ca4686a --- /dev/null +++ b/src/ingot/db/engine.py @@ -0,0 +1,60 @@ +"""Async SQLite engine with WAL mode, session factory, and table initialisation.""" +from pathlib import Path + +from sqlalchemy import event, text +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker +from sqlmodel import SQLModel + + +def _get_database_url(base_dir: Path | None = None) -> str: + if base_dir: + db_path = (base_dir / "outreach.db").as_posix() + else: + from ingot.config.manager import ConfigManager # pylint: disable=import-outside-toplevel + cm = ConfigManager() + db_path = Path(cm.get_db_path()).as_posix() + return f"sqlite+aiosqlite:///{db_path}" + + +def create_engine(database_url: str): + """Create an async SQLite engine with WAL mode and performance PRAGMAs.""" + eng = create_async_engine( + database_url, + echo=False, + connect_args={"check_same_thread": False}, + ) + + @event.listens_for(eng.sync_engine, "connect") + def set_sqlite_pragma(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA synchronous=NORMAL") + cursor.execute("PRAGMA cache_size=-64000") # 64 MB page cache + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + return eng + + +# Module-level engine (overridable in tests via dependency injection) +engine = create_engine(_get_database_url()) + +AsyncSessionLocal = sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False +) + + +async def get_session(): + """Async context manager yielding an AsyncSession.""" + async with AsyncSessionLocal() as session: + yield session + + +async def init_db(eng=None): + """Create all tables from SQLModel metadata. Used for fresh installs and tests.""" + # Import all models so they are registered in SQLModel.metadata + from ingot.db import models as _ # noqa: F401 # pylint: disable=import-outside-toplevel + target_engine = eng or engine + async with target_engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) diff --git a/src/ingot/db/models.py b/src/ingot/db/models.py new file mode 100644 index 0000000..35668e7 --- /dev/null +++ b/src/ingot/db/models.py @@ -0,0 +1,259 @@ +"""All 11 SQLModel table models for INGOT. + +Import this module (or individual models) to register them in SQLModel.metadata +before calling create_all() or running Alembic autogenerate. +""" +from __future__ import annotations + +import enum +from datetime import datetime +from typing import Optional + +from sqlalchemy import Column, JSON +from sqlmodel import Field, SQLModel + + +# --------------------------------------------------------------------------- +# Enum types (stored as str in SQLite — no database-level enum) +# --------------------------------------------------------------------------- + +class ContactType(str, enum.Enum): + """Supported contact channel types for a Lead. + + ``email`` is the only channel used for outreach in v1. + All others are stored for research context and future outreach channels. + + Professional: + email, linkedin, phone, calendly + + Developer presence: + github, stackoverflow + + Social / content: + twitter, medium, substack, youtube + + Startup ecosystem: + angellist, crunchbase, producthunt + + Web: + website, portfolio + """ + # Professional + email = "email" + linkedin = "linkedin" + phone = "phone" + calendly = "calendly" + + # Developer presence + github = "github" + stackoverflow = "stackoverflow" + + # Social / content + twitter = "twitter" + medium = "medium" + substack = "substack" + youtube = "youtube" + + # Startup ecosystem + angellist = "angellist" + crunchbase = "crunchbase" + producthunt = "producthunt" + + # Web + website = "website" + portfolio = "portfolio" + + +class LeadStatus(str, enum.Enum): + """Lifecycle status of a Lead through the pipeline.""" + + discovered = "discovered" + researching = "researching" + matched = "matched" + drafted = "drafted" + sent = "sent" + replied = "replied" + + +class EmailStatus(str, enum.Enum): + """Lifecycle status of a drafted outreach Email.""" + + drafted = "drafted" + approved = "approved" + sent = "sent" + bounced = "bounced" + opened = "opened" + + +class FollowUpStatus(str, enum.Enum): + """Scheduling status of a follow-up message.""" + + queued = "queued" + sent = "sent" + skipped = "skipped" + + +class CampaignStatus(str, enum.Enum): + """Operational status of an outreach Campaign.""" + + active = "active" + paused = "paused" + completed = "completed" + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + +class UserProfile(SQLModel, table=True): + """DB-01 — Candidate profile parsed from resume / LinkedIn.""" + id: Optional[int] = Field(default=None, primary_key=True) + name: str + headline: str = "" + skills: list[str] = Field(default_factory=list, sa_column=Column(JSON, nullable=False)) + experience: list[dict] = Field(default_factory=list, sa_column=Column(JSON, nullable=False)) + education: list[dict] = Field(default_factory=list, sa_column=Column(JSON, nullable=False)) + projects: list[dict] = Field(default_factory=list, sa_column=Column(JSON, nullable=False)) + github_url: str = "" + linkedin_url: str = "" + resume_raw_text: str = "" + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + +class Lead(SQLModel, table=True): + """DB-02 — A company / person discovered by the Scout agent.""" + id: Optional[int] = Field(default=None, primary_key=True) + company_name: str + person_name: str = "" + person_email: str = "" + person_role: str = "" + company_website: str = "" + source_venue: str = "" + status: LeadStatus = LeadStatus.discovered + initial_score: float = 0.0 + created_at: datetime = Field(default_factory=datetime.utcnow) + + +class LeadContact(SQLModel, table=True): + """DB-02b — Typed contact details for a Lead. + + One row per contact channel. Multiple rows may exist for the same lead + and the same contact_type (e.g., two email addresses found by Research). + + ``is_primary`` marks the preferred contact for outreach within a type. + Only ``email`` contacts are used for sending in v1; all types are stored + for research context and future channel support. + """ + id: Optional[int] = Field(default=None, primary_key=True) + lead_id: int = Field(foreign_key="lead.id", index=True) + contact_type: ContactType + value: str + is_primary: bool = False + created_at: datetime = Field(default_factory=datetime.utcnow) + + +class IntelBrief(SQLModel, table=True): + """DB-03 — Research brief created by the Research agent for a lead.""" + id: Optional[int] = Field(default=None, primary_key=True) + company_name: str + company_signals: list[str] = Field(default_factory=list, sa_column=Column(JSON, nullable=False)) + person_name: str = "" + person_role: str = "" + company_website: str = "" + person_background: str = "" + talking_points: list[str] = Field(default_factory=list, sa_column=Column(JSON, nullable=False)) + company_product_description: str = "" + lead_id: Optional[int] = Field(default=None, foreign_key="lead.id") + created_at: datetime = Field(default_factory=datetime.utcnow) + + +class Match(SQLModel, table=True): + """DB-04 — Match score and value prop produced by the Matcher agent.""" + id: Optional[int] = Field(default=None, primary_key=True) + match_score: float + value_proposition: str + confidence_level: str + lead_id: Optional[int] = Field(default=None, foreign_key="lead.id") + created_at: datetime = Field(default_factory=datetime.utcnow) + + +class Email(SQLModel, table=True): + """DB-05 — Outreach email drafted by the Writer agent.""" + id: Optional[int] = Field(default=None, primary_key=True) + subject_a: str + subject_b: str = "" + body: str + tone_adapted_for: str = "" + mcq_answers_json: str = "{}" + status: EmailStatus = EmailStatus.drafted + lead_id: Optional[int] = Field(default=None, foreign_key="lead.id") + created_at: datetime = Field(default_factory=datetime.utcnow) + + +class FollowUp(SQLModel, table=True): + """DB-06 — Scheduled follow-up messages for an email thread.""" + id: Optional[int] = Field(default=None, primary_key=True) + parent_email_id: Optional[int] = Field(default=None, foreign_key="email.id") + scheduled_for_day: int + body: str + status: FollowUpStatus = FollowUpStatus.queued + created_at: datetime = Field(default_factory=datetime.utcnow) + sent_at: Optional[datetime] = None + + +class Campaign(SQLModel, table=True): + """DB-07 — A named outreach campaign grouping many leads.""" + id: Optional[int] = Field(default=None, primary_key=True) + campaign_name: str + created_at: datetime = Field(default_factory=datetime.utcnow) + started_at: Optional[datetime] = None + ended_at: Optional[datetime] = None + total_leads: int = 0 + total_sent: int = 0 + total_replied: int = 0 + status: CampaignStatus = CampaignStatus.active + + +class AgentLog(SQLModel, table=True): + """DB-08 — Per-step execution log for each agent run.""" + id: Optional[int] = Field(default=None, primary_key=True) + agent_name: str + step_description: str + status: str + duration_ms: int = 0 + error_message: str = "" + input_tokens: int = 0 + output_tokens: int = 0 + cost_estimate: float = 0.0 + created_at: datetime = Field(default_factory=datetime.utcnow) + + +class Venue(SQLModel, table=True): + """DB-09 — Scout venue config (YC, LinkedIn, etc.).""" + id: Optional[int] = Field(default=None, primary_key=True) + venue_name: str + venue_type: str + config_json: str = "{}" + last_run_at: Optional[datetime] = None + lead_count_discovered: int = 0 + last_error: str = "" + + +class OutreachMetric(SQLModel, table=True): + """DB-10 — Rolling send-rate metrics for bounce / spam guard.""" + id: Optional[int] = Field(default=None, primary_key=True) + sent_today: int = 0 + sent_this_hour: int = 0 + bounce_count: int = 0 + bounce_rate: float = 0.0 + last_sent_at: Optional[datetime] = None + created_at: datetime = Field(default_factory=datetime.utcnow) + + +class UnsubscribedEmail(SQLModel, table=True): + """DB-11 — Permanently unsubscribed addresses; never contact again.""" + id: Optional[int] = Field(default=None, primary_key=True) + email_address: str = Field(index=True) + unsubscribe_reason: str = "" + unsubscribed_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/src/ingot/db/repositories/__init__.py b/src/ingot/db/repositories/__init__.py new file mode 100644 index 0000000..916fe41 --- /dev/null +++ b/src/ingot/db/repositories/__init__.py @@ -0,0 +1,4 @@ +"""Repository package: generic async CRUD base and model-specific repos.""" +from ingot.db.repositories.base import BaseRepository + +__all__ = ["BaseRepository"] diff --git a/src/ingot/db/repositories/base.py b/src/ingot/db/repositories/base.py new file mode 100644 index 0000000..1c187ea --- /dev/null +++ b/src/ingot/db/repositories/base.py @@ -0,0 +1,44 @@ +"""Generic async repository providing CRUD operations over any SQLModel table.""" +from __future__ import annotations + +from typing import Generic, TypeVar + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import select + +T = TypeVar("T") + + +class BaseRepository(Generic[T]): + """Generic async repository providing CRUD operations over any SQLModel table.""" + + def __init__(self, session: AsyncSession, model: type[T]): + self.session = session + self.model = model + + async def add(self, obj: T) -> T: + """Persist a new object and return it refreshed from the database.""" + self.session.add(obj) + await self.session.commit() + await self.session.refresh(obj) + return obj + + async def get(self, obj_id: int) -> T | None: + """Fetch a single record by primary key, or None if not found.""" + return await self.session.get(self.model, obj_id) + + async def list(self, limit: int = 100, offset: int = 0) -> list[T]: + """Return a paginated list of all records for this model.""" + result = await self.session.execute( + select(self.model).limit(limit).offset(offset) + ) + return list(result.scalars().all()) + + async def delete(self, obj_id: int) -> bool: + """Delete a record by primary key. Returns True if deleted, False if not found.""" + obj = await self.get(obj_id) + if obj is None: + return False + await self.session.delete(obj) + await self.session.commit() + return True diff --git a/src/ingot/dispatcher.py b/src/ingot/dispatcher.py new file mode 100644 index 0000000..4cef537 --- /dev/null +++ b/src/ingot/dispatcher.py @@ -0,0 +1,74 @@ +""" +Async task dispatcher using asyncio.Queue. + +Redis upgrade path is isolated here — swap queue internals in v2 without +touching any agent or orchestrator code. +""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from typing import Any, Callable + + +@dataclass +class TaskResult: + """Result container for a single dispatched async task.""" + task_name: str + success: bool + result: Any = None + error: Exception | None = None + + +class AsyncTaskDispatcher: + """ + Worker pool over asyncio.Queue. + + Usage:: + + dispatcher = AsyncTaskDispatcher(max_workers=3) + dispatcher.enqueue("scout", scout_fn, deps=deps, batch=companies) + results = await dispatcher.run_all() + + All enqueued tasks run concurrently up to ``max_workers``. + Failed tasks record the exception in TaskResult.error instead of propagating. + """ + + def __init__(self, max_workers: int = 3) -> None: + self.max_workers = max_workers + self._queue: asyncio.Queue[tuple[str, Callable, dict]] = asyncio.Queue() + self._results: list[TaskResult] = [] + + def enqueue(self, task_name: str, coro_fn: Callable, **kwargs: Any) -> None: + """Add a coroutine task to the queue. coro_fn must be an async callable.""" + self._queue.put_nowait((task_name, coro_fn, kwargs)) + + async def run_all(self) -> list[TaskResult]: + """ + Drain the queue using ``max_workers`` concurrent workers. + + Returns all TaskResults (successes and failures) in completion order. + Safe to call on an empty queue — returns [] immediately. + """ + self._results = [] + workers = [asyncio.create_task(self._worker()) for _ in range(self.max_workers)] + await asyncio.gather(*workers) + return self._results + + async def _worker(self) -> None: + while True: + try: + task_name, coro_fn, kwargs = self._queue.get_nowait() + except asyncio.QueueEmpty: + return + try: + result = await coro_fn(**kwargs) + self._results.append( + TaskResult(task_name=task_name, success=True, result=result) + ) + except Exception as exc: # noqa: BLE001 + self._results.append( + TaskResult(task_name=task_name, success=False, error=exc) + ) + finally: + self._queue.task_done() diff --git a/src/ingot/http_client.py b/src/ingot/http_client.py new file mode 100644 index 0000000..6efc032 --- /dev/null +++ b/src/ingot/http_client.py @@ -0,0 +1,66 @@ +""" +Shared async HTTP client with connection pooling. + +All agents use this — never create httpx.AsyncClient() inline. +One client per process; reused across requests to avoid TCP handshake overhead. +""" +from __future__ import annotations + +from dataclasses import dataclass + +import httpx + +_client: httpx.AsyncClient | None = None +_config_snapshot: "HttpClientConfig | None" = None + +_DEFAULT_USER_AGENT = ( + "Mozilla/5.0 (compatible; INGOT/0.1; +https://github.com/ingot)" +) + + +@dataclass +class HttpClientConfig: + """Configuration for the shared async HTTP client.""" + max_keepalive_connections: int = 5 + max_connections: int = 10 + timeout_seconds: float = 30.0 + request_delay_seconds: float = 1.0 # Polite scraping delay + + +def get_http_client(config: HttpClientConfig | None = None) -> httpx.AsyncClient: + """ + Return the shared AsyncClient. Creates it on first call. + + Pass ``config`` to override defaults; only applied on first creation or after + close_http_client(). In tests, call close_http_client() in teardown to reset. + """ + global _client, _config_snapshot + + if config is not None: + _config_snapshot = config + + effective = _config_snapshot or HttpClientConfig() + + if _client is None or _client.is_closed: + _client = httpx.AsyncClient( + limits=httpx.Limits( + max_keepalive_connections=effective.max_keepalive_connections, + max_connections=effective.max_connections, + ), + timeout=httpx.Timeout(effective.timeout_seconds), + headers={ + "User-Agent": _DEFAULT_USER_AGENT, + "Accept": "text/html,application/json,*/*", + }, + follow_redirects=True, + ) + return _client + + +async def close_http_client() -> None: + """Close and reset the shared client. Call in test teardown or on shutdown.""" + global _client, _config_snapshot + if _client is not None and not _client.is_closed: + await _client.aclose() + _client = None + _config_snapshot = None diff --git a/src/ingot/llm/__init__.py b/src/ingot/llm/__init__.py new file mode 100644 index 0000000..26af0e2 --- /dev/null +++ b/src/ingot/llm/__init__.py @@ -0,0 +1,4 @@ +"""LLM package: unified client and typed request/response schemas.""" +from ingot.llm.client import LLMClient + +__all__ = ["LLMClient"] diff --git a/src/ingot/llm/client.py b/src/ingot/llm/client.py new file mode 100644 index 0000000..a810609 --- /dev/null +++ b/src/ingot/llm/client.py @@ -0,0 +1,123 @@ +"""LLMClient — the single unified LLM abstraction for all INGOT agents. + +Routes to any LiteLLM-supported backend (Claude, OpenAI, Ollama, OpenAI-compatible). +Response path priority: + 1. Native tool call → JSON parse → Pydantic validate + 2. Content as JSON → Pydantic validate + 3. XML tag fallback → Pydantic validate +Raises LLMError after all retries; LLMValidationError when response cannot be parsed. +""" +from __future__ import annotations + +import logging +import re +from typing import Type, TypeVar + +from litellm import acompletion +from pydantic import BaseModel +from tenacity import ( + before_sleep_log, + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) + +from ingot.agents.exceptions import LLMError, LLMValidationError +from ingot.llm.fallback import xml_extract + +T = TypeVar("T", bound=BaseModel) +logger = logging.getLogger("ingot.llm") + + +class LLMClient: + """Unified LLM client routing to any LiteLLM-supported backend with retry logic.""" + + def __init__(self, model: str, max_retries: int = 3): + self.model = model + self.max_retries = max_retries + self._retry_decorator = retry( + stop=stop_after_attempt(max_retries), + wait=wait_exponential(multiplier=1, min=2, max=30), + retry=retry_if_exception_type(LLMError), + before_sleep=before_sleep_log(logger, logging.WARNING), + reraise=True, + ) + + async def complete( + self, + messages: list[dict], + response_schema: Type[T], + tools: list[dict] | None = None, + *, + use_xml_fallback: bool = True, + ) -> T: + """Call LLM and return a validated Pydantic instance. + + Args: + messages: List of {"role": ..., "content": ...} dicts. + response_schema: Pydantic model class to validate the response against. + tools: Optional list of tool definitions for structured output. + use_xml_fallback: Fall back to XML tag extraction when JSON parsing fails. + + Returns: + Validated instance of response_schema. + + Raises: + LLMError: Backend unreachable or all retries exhausted. + LLMValidationError: Response received but cannot be parsed/validated. + """ + inner = self._retry_decorator(self._call_once) + return await inner(messages, response_schema, tools, use_xml_fallback) + + async def _call_once( + self, + messages: list[dict], + response_schema: Type[T], + tools: list[dict] | None, + use_xml_fallback: bool, + ) -> T: + try: + kwargs: dict = {"model": self.model, "messages": messages} + if tools: + kwargs["tools"] = tools + kwargs["tool_choice"] = "auto" + + response = await acompletion(**kwargs) + raw = response.choices[0].message + finish_reason = response.choices[0].finish_reason or "" + logger.debug("LLM finish_reason=%s", finish_reason) + + # Path 1: Native tool call + if raw.tool_calls: + args_json = raw.tool_calls[0].function.arguments + try: + return response_schema.model_validate_json(args_json) + except Exception as e: + logger.debug( + "Tool call JSON validation failed, trying content fallback: %s", e + ) + + # Path 2: Content as JSON (strip markdown fences if present) + content = raw.content or "" + if content: + json_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", content) + json_str = json_match.group(1).strip() if json_match else content.strip() + try: + return response_schema.model_validate_json(json_str) + except Exception: + pass # fall through to XML + + # Path 3: XML tag extraction + if use_xml_fallback and content: + return xml_extract(content, response_schema) + + raise LLMValidationError( + f"LLM response could not be parsed for schema {response_schema.__name__}", + raw_content=content, + ) + + except (LLMValidationError, LLMError): + raise # already typed — don't wrap + except Exception as e: + raise LLMError(f"LLM backend error: {e}", cause=e) from e diff --git a/src/ingot/llm/fallback.py b/src/ingot/llm/fallback.py new file mode 100644 index 0000000..8233937 --- /dev/null +++ b/src/ingot/llm/fallback.py @@ -0,0 +1,57 @@ +"""XML tag extraction fallback for LLM models without structured tool-call support.""" +from __future__ import annotations + +import re +import types +import typing +from typing import Type, TypeVar + +from pydantic import BaseModel + +from ingot.agents.exceptions import LLMValidationError + +T = TypeVar("T", bound=BaseModel) + + +def xml_extract(content: str, schema: Type[T]) -> T: + """ + Extract field values from XML-like tags in LLM text output and validate + against a Pydantic schema. + + Flat schemas only — nested objects are not supported. + List fields are populated by splitting on newlines inside the tag. + + Example input: + Acme Corp + Python + Go + Rust + """ + data: dict = {} + for field_name, field_info in schema.model_fields.items(): + pattern = rf"<{field_name}>(.*?)" + match = re.search(pattern, content, re.DOTALL) + if match: + raw_value = match.group(1).strip() + annotation = field_info.annotation + # Unwrap Optional / Union (e.g. list[str] | None → list[str]) + # Handles both typing.Union (Optional[X]) and PEP-604 X | None syntax + origin = typing.get_origin(annotation) + if origin is typing.Union or isinstance(annotation, types.UnionType): + args = [a for a in typing.get_args(annotation) if a is not types.NoneType] + annotation = args[0] if args else annotation + origin = typing.get_origin(annotation) + if origin is list: + data[field_name] = [ + line.strip() for line in raw_value.splitlines() if line.strip() + ] + else: + data[field_name] = raw_value + try: + return schema.model_validate(data) + except Exception as e: + raise LLMValidationError( + f"XML fallback validation failed for {schema.__name__}: {e}", + raw_content=content, + cause=e, + ) from e diff --git a/src/ingot/llm/schemas.py b/src/ingot/llm/schemas.py new file mode 100644 index 0000000..931f7e5 --- /dev/null +++ b/src/ingot/llm/schemas.py @@ -0,0 +1,30 @@ +"""Pydantic models for LLM request/response envelopes (internal use).""" +# NOTE: These models are not yet wired into LLMClient (which accepts list[dict] for +# compatibility with litellm's interface). They serve as planned typed contracts for +# a future refactor — keep them in sync with the client's actual behaviour. +from __future__ import annotations + +from pydantic import BaseModel + + +class LLMMessage(BaseModel): + """A single message in an LLM conversation (role + content).""" + + role: str # "system" | "user" | "assistant" + content: str + + +class LLMRequest(BaseModel): + """Typed envelope for an LLM completion request (internal use).""" + + model: str + messages: list[LLMMessage] + tools: list[dict] | None = None + + +class LLMResponse(BaseModel): + """Internal envelope — callers receive the validated schema instance, not this.""" + content: str + tool_call_args: str | None = None # JSON string if tool call + finish_reason: str + used_xml_fallback: bool = False diff --git a/src/ingot/logging_config.py b/src/ingot/logging_config.py new file mode 100644 index 0000000..7b85a3e --- /dev/null +++ b/src/ingot/logging_config.py @@ -0,0 +1,93 @@ +"""Structured logging configuration for INGOT using structlog.""" +from __future__ import annotations + +import logging +import logging.handlers +import sys +from datetime import datetime +from pathlib import Path + +import structlog + + +def configure_logging(base_dir: Path, verbosity: int = 0) -> None: + """Configure structlog with stderr and rotating file handlers. + + Args: + base_dir: Base directory for log files (e.g., ~/.ingot/). + verbosity: 0=WARNING, 1=INFO (-v), 2=DEBUG (-vv). + """ + log_level = { + 0: logging.WARNING, + 1: logging.INFO, + 2: logging.DEBUG, + }.get(verbosity, logging.DEBUG) + + # Ensure log directory exists + log_dir = base_dir / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + + date_str = datetime.now().strftime("%Y-%m-%d") + log_file = log_dir / f"run-{date_str}.log" + + # Root logger setup + root_logger = logging.getLogger() + root_logger.setLevel(log_level) + for handler in root_logger.handlers[:]: + handler.close() + root_logger.removeHandler(handler) + + # Stderr handler — WARNING+ only, human-readable + stderr_handler = logging.StreamHandler(sys.stderr) + stderr_handler.setLevel(logging.WARNING) + stderr_handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + root_logger.addHandler(stderr_handler) + + # Rotating file handler — DEBUG+, JSON via structlog + file_handler = logging.handlers.RotatingFileHandler( + log_file, + maxBytes=5 * 1024 * 1024, # 5 MB + backupCount=5, + encoding="utf-8", + ) + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter(logging.Formatter("%(message)s")) + root_logger.addHandler(file_handler) + + # Shared processors for both handlers + shared_processors: list[structlog.types.Processor] = [ + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + ] + + structlog.configure( + processors=[ + *shared_processors, + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + ) + + # Configure stdlib formatter for stderr (human-readable) + stderr_formatter = structlog.stdlib.ProcessorFormatter( + processor=structlog.dev.ConsoleRenderer(), + foreign_pre_chain=shared_processors, + ) + stderr_handler.setFormatter(stderr_formatter) + + # Configure stdlib formatter for file (JSON) + file_formatter = structlog.stdlib.ProcessorFormatter( + processor=structlog.processors.JSONRenderer(), + foreign_pre_chain=shared_processors, + ) + file_handler.setFormatter(file_formatter) + + +def get_logger(name: str) -> structlog.stdlib.BoundLogger: + """Return a structlog BoundLogger for the given name.""" + return structlog.get_logger(name) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c05c433 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,42 @@ +"""Shared pytest fixtures for INGOT Phase 1 tests.""" +from __future__ import annotations + +from pathlib import Path + +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker +from sqlmodel import SQLModel + + +@pytest.fixture +def tmp_config_dir(tmp_path: Path) -> Path: + """Return a temp dir that acts as the INGOT base_dir (~/.ingot substitute).""" + config_dir = tmp_path / "ingot" + config_dir.mkdir() + return config_dir + + +@pytest_asyncio.fixture +async def in_memory_engine(): + """In-memory aiosqlite engine with all tables created. Disposed after each test.""" + engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False) + + # Register all models in SQLModel.metadata + from ingot.db import models as _ # noqa: F401 + + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + yield engine + + await engine.dispose() + + +@pytest_asyncio.fixture +async def async_session(in_memory_engine): + """Yield a single AsyncSession over the in-memory engine.""" + factory = sessionmaker(in_memory_engine, class_=AsyncSession, expire_on_commit=False) + async with factory() as session: + yield session diff --git a/tests/test_agents_base.py b/tests/test_agents_base.py new file mode 100644 index 0000000..f296d81 --- /dev/null +++ b/tests/test_agents_base.py @@ -0,0 +1,56 @@ +"""Tests for ingot.agents.base.""" +from unittest.mock import MagicMock + +import httpx + +from ingot.agents.base import AgentDeps, AgentRunResult, StepResult +from ingot.llm.client import LLMClient + + +def make_deps(): + return AgentDeps( + llm_client=MagicMock(spec=LLMClient), + session=MagicMock(), + http_client=MagicMock(spec=httpx.AsyncClient), + ) + + +def test_agent_deps_construction(): + deps = make_deps() + assert deps.verbosity == 0 + assert deps.agent_name == "" + + +def test_step_result_success(): + s = StepResult(step="fetch", success=True, output={"data": 42}) + assert s.success + assert s.output["data"] == 42 + + +def test_step_result_failure(): + err = RuntimeError("boom") + s = StepResult(step="score", success=False, error=err) + assert not s.success + assert s.error is err + + +def test_agent_run_result_failed_step_none(): + run = AgentRunResult( + agent_name="scout", + success=True, + steps=[StepResult("a", True), StepResult("b", True)], + ) + assert run.failed_step is None + + +def test_agent_run_result_failed_step_returns_first(): + run = AgentRunResult( + agent_name="scout", + success=False, + steps=[ + StepResult("a", True), + StepResult("b", False, error=RuntimeError("err1")), + StepResult("c", False, error=RuntimeError("err2")), + ], + ) + assert run.failed_step.step == "b" diff --git a/tests/test_agents_exceptions.py b/tests/test_agents_exceptions.py new file mode 100644 index 0000000..70d1801 --- /dev/null +++ b/tests/test_agents_exceptions.py @@ -0,0 +1,44 @@ +"""Tests for ingot.agents.exceptions.""" +import pytest + +from ingot.agents.exceptions import ( + AgentError, + ConfigError, + DBError, + IngotError, + LLMError, + LLMValidationError, + ValidationError, +) + + +def test_ingot_error_str_no_cause(): + e = IngotError("something broke") + assert str(e) == "something broke" + + +def test_ingot_error_str_with_cause(): + cause = ValueError("bad value") + e = IngotError("wrapper", cause=cause) + assert "bad value" in str(e) + assert "ValueError" in str(e) + + +def test_llm_validation_error_stores_raw(): + e = LLMValidationError("parse failed", raw_content="xml") + assert e.raw_content == "xml" + + +def test_agent_error_stores_agent_name(): + e = AgentError("scout", "scrape failed") + assert e.agent_name == "scout" + assert "scout" in str(e) + + +def test_hierarchy(): + assert issubclass(LLMError, IngotError) + assert issubclass(LLMValidationError, IngotError) + assert issubclass(DBError, IngotError) + assert issubclass(ConfigError, IngotError) + assert issubclass(ValidationError, IngotError) + assert issubclass(AgentError, IngotError) diff --git a/tests/test_agents_imports.py b/tests/test_agents_imports.py new file mode 100644 index 0000000..5cbd02f --- /dev/null +++ b/tests/test_agents_imports.py @@ -0,0 +1,43 @@ +"""AGENT-05 enforcement and registry population tests.""" +import ast +from pathlib import Path + +AGENT_NAMES = ["orchestrator", "scout", "research", "matcher", "writer", "outreach", "analyst"] +AGENTS_DIR = Path(__file__).parent.parent / "src" / "ingot" / "agents" + + +def test_all_agents_in_registry(): + """Importing ingot.agents triggers self-registration of all 6 non-Orchestrator agents.""" + import ingot.agents # noqa: F401 — triggers all register_agent() calls + from ingot.agents.registry import AGENT_REGISTRY + for name in ["scout", "research", "matcher", "writer", "outreach", "analyst"]: + assert name in AGENT_REGISTRY, f"Agent '{name}' not in AGENT_REGISTRY" + + +def test_agent05_no_cross_agent_imports(): + """AGENT-05: No agent module imports from another agent module.""" + agent_modules = {f.stem for f in AGENTS_DIR.glob("*.py") if not f.stem.startswith("_")} + + violations = [] + for agent_file in AGENTS_DIR.glob("*.py"): + if agent_file.stem.startswith("_"): + continue + tree = ast.parse(agent_file.read_text()) + for node in ast.walk(tree): + if isinstance(node, (ast.Import, ast.ImportFrom)): + if isinstance(node, ast.ImportFrom) and node.module: + # Check if importing from ingot.agents. + parts = node.module.split(".") + if ( + len(parts) >= 3 + and parts[0] == "ingot" + and parts[1] == "agents" + and parts[2] in agent_modules + and parts[2] != agent_file.stem + and parts[2] not in ("base", "registry", "exceptions") + ): + violations.append( + f"{agent_file.stem} imports from ingot.agents.{parts[2]}" + ) + + assert not violations, f"AGENT-05 violated: {violations}" diff --git a/tests/test_agents_pipeline.py b/tests/test_agents_pipeline.py new file mode 100644 index 0000000..a21a51c --- /dev/null +++ b/tests/test_agents_pipeline.py @@ -0,0 +1,160 @@ +"""Tests for agent pipeline contracts: run(), run_step(), error propagation. + +These tests verify the behavioral contracts the Orchestrator relies on: +- run() executes steps in declared order +- run() stops at the first failed step +- run() respects a subset of steps when passed explicitly +- run_step() dispatches to the correct step implementation +- run_step() raises ValueError for unknown step names +""" +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from ingot.agents.base import AgentDeps, AgentRunResult, StepResult +from ingot.llm.client import LLMClient + + +def make_deps() -> AgentDeps: + return AgentDeps( + llm_client=MagicMock(spec=LLMClient), + session=MagicMock(), + http_client=MagicMock(spec=httpx.AsyncClient), + ) + + +# ─── ScoutAgent ─────────────────────────────────────────────────────────────── + +class TestScoutPipeline: + @pytest.fixture + def scout(self): + from ingot.agents.scout import ScoutAgent + return ScoutAgent() + + async def test_run_executes_all_steps(self, scout): + result = await scout.run(make_deps()) + assert result.success + assert [s.step for s in result.steps] == ["discover", "deduplicate", "score"] + + async def test_run_stops_on_first_failure(self, scout): + """If 'deduplicate' fails, 'score' must not run.""" + fail = StepResult(step="deduplicate", success=False, error=RuntimeError("db error")) + with patch.object(scout, "_deduplicate", AsyncMock(return_value=fail)): + result = await scout.run(make_deps()) + assert not result.success + step_names = [s.step for s in result.steps] + assert "score" not in step_names + + async def test_run_partial_steps(self, scout): + result = await scout.run(make_deps(), steps=["discover"]) + assert len(result.steps) == 1 + assert result.steps[0].step == "discover" + + async def test_run_step_invalid_raises_value_error(self, scout): + with pytest.raises(ValueError, match="Scout has no step"): + await scout.run_step("nonexistent", make_deps()) + + async def test_run_step_discover(self, scout): + result = await scout.run_step("discover", make_deps()) + assert result.success + assert result.step == "discover" + + +# ─── AnalystAgent ───────────────────────────────────────────────────────────── + +class TestAnalystPipeline: + @pytest.fixture + def analyst(self): + from ingot.agents.analyst import AnalystAgent + return AnalystAgent() + + async def test_run_executes_all_steps(self, analyst): + result = await analyst.run(make_deps()) + assert result.success + assert [s.step for s in result.steps] == ["aggregate", "identify_patterns", "generate_insights"] + + async def test_run_step_invalid_raises_value_error(self, analyst): + with pytest.raises(ValueError, match="Analyst has no step"): + await analyst.run_step("nonexistent", make_deps()) + + async def test_run_step_aggregate(self, analyst): + result = await analyst.run_step("aggregate", make_deps()) + assert result.success + + +# ─── MatcherAgent ───────────────────────────────────────────────────────────── + +class TestMatcherPipeline: + @pytest.fixture + def matcher(self): + from ingot.agents.matcher import MatcherAgent + return MatcherAgent() + + async def test_run_executes_all_steps(self, matcher): + result = await matcher.run(make_deps()) + assert result.success + assert [s.step for s in result.steps] == ["load_profile", "compare", "score"] + + async def test_run_step_invalid_raises_value_error(self, matcher): + with pytest.raises(ValueError, match="Matcher has no step"): + await matcher.run_step("nonexistent", make_deps()) + + async def test_all_steps_reachable(self, matcher): + deps = make_deps() + for step in ["load_profile", "compare", "score"]: + r = await matcher.run_step(step, deps) + assert r.success, f"Step '{step}' returned failure unexpectedly" + + +# ─── ResearchAgent ──────────────────────────────────────────────────────────── + +class TestResearchPipeline: + @pytest.fixture + def research(self): + from ingot.agents.research import ResearchAgent + return ResearchAgent() + + async def test_run_executes_all_steps(self, research): + result = await research.run(make_deps()) + assert result.success + + async def test_run_step_invalid_raises_value_error(self, research): + with pytest.raises(ValueError): + await research.run_step("nonexistent", make_deps()) + + +# ─── WriterAgent ────────────────────────────────────────────────────────────── + +class TestWriterPipeline: + @pytest.fixture + def writer(self): + from ingot.agents.writer import WriterAgent + return WriterAgent() + + async def test_run_executes_all_steps(self, writer): + result = await writer.run(make_deps()) + assert result.success + + async def test_run_step_invalid_raises_value_error(self, writer): + with pytest.raises(ValueError): + await writer.run_step("nonexistent", make_deps()) + + +# ─── OutreachAgent ──────────────────────────────────────────────────────────── + +class TestOutreachPipeline: + @pytest.fixture + def outreach(self): + from ingot.agents.outreach import OutreachAgent + return OutreachAgent() + + async def test_run_executes_all_steps(self, outreach): + result = await outreach.run(make_deps()) + assert result.success + + async def test_run_step_invalid_raises_value_error(self, outreach): + with pytest.raises(ValueError): + await outreach.run_step("nonexistent", make_deps()) diff --git a/tests/test_agents_registry.py b/tests/test_agents_registry.py new file mode 100644 index 0000000..8fae83d --- /dev/null +++ b/tests/test_agents_registry.py @@ -0,0 +1,32 @@ +"""Tests for ingot.agents.registry.""" +import pytest + +from ingot.agents.registry import AGENT_REGISTRY, get_agent, list_agents, register_agent + + +@pytest.fixture(autouse=True) +def _clean_registry(): + """Backup and restore AGENT_REGISTRY around each test.""" + snapshot = dict(AGENT_REGISTRY) + yield + AGENT_REGISTRY.clear() + AGENT_REGISTRY.update(snapshot) + + +def test_register_and_get(): + mock_agent = object() + register_agent("test_agent", mock_agent) + assert get_agent("test_agent") is mock_agent + + +def test_get_missing_raises_key_error(): + with pytest.raises(KeyError, match="not in registry"): + get_agent("nonexistent_agent_xyz") + + +def test_list_agents_sorted(): + AGENT_REGISTRY.clear() + register_agent("zebra", object()) + register_agent("apple", object()) + register_agent("mango", object()) + assert list_agents() == ["apple", "mango", "zebra"] diff --git a/tests/test_cli_setup_noninteractive.py b/tests/test_cli_setup_noninteractive.py new file mode 100644 index 0000000..220ee59 --- /dev/null +++ b/tests/test_cli_setup_noninteractive.py @@ -0,0 +1,58 @@ +"""Tests for non-interactive setup validation in ingot.cli.setup.""" +import pytest +import typer + +from ingot.cli.setup import _run_non_interactive +from ingot.config.schema import AppConfig + + +def _fresh_cfg() -> AppConfig: + return AppConfig() + + +def test_fully_free_preset_needs_no_api_keys(monkeypatch, tmp_path): + """fully_free uses Ollama only — no API keys required.""" + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("GMAIL_USERNAME", raising=False) + cfg = _fresh_cfg() + # Should not raise + _run_non_interactive(cfg, preset="fully_free") + assert all("ollama" in a.model for a in cfg.agents.values()) + + +def test_best_quality_without_anthropic_key_exits(monkeypatch): + """best_quality preset selects Anthropic models; missing key must error.""" + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("GMAIL_USERNAME", raising=False) + cfg = _fresh_cfg() + with pytest.raises(typer.Exit): + _run_non_interactive(cfg, preset="best_quality") + + +def test_best_quality_with_anthropic_key_succeeds(monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + monkeypatch.delenv("GMAIL_USERNAME", raising=False) + cfg = _fresh_cfg() + _run_non_interactive(cfg, preset="best_quality") + assert cfg.anthropic_api_key == "sk-ant-test" + + +def test_gmail_username_without_password_exits(monkeypatch): + """Providing Gmail username but no app password must error.""" + monkeypatch.setenv("GMAIL_USERNAME", "user@gmail.com") + monkeypatch.delenv("GMAIL_APP_PASSWORD", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + cfg = _fresh_cfg() + with pytest.raises(typer.Exit): + _run_non_interactive(cfg, preset="fully_free") + + +def test_gmail_username_with_password_succeeds(monkeypatch): + monkeypatch.setenv("GMAIL_USERNAME", "user@gmail.com") + monkeypatch.setenv("GMAIL_APP_PASSWORD", "app-pass") + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + cfg = _fresh_cfg() + _run_non_interactive(cfg, preset="fully_free") + assert cfg.smtp.username == "user@gmail.com" + assert cfg.smtp.password == "app-pass" diff --git a/tests/test_config_crypto.py b/tests/test_config_crypto.py new file mode 100644 index 0000000..3ce7a85 --- /dev/null +++ b/tests/test_config_crypto.py @@ -0,0 +1,48 @@ +"""Tests for ingot.config.crypto.""" +import stat + +import pytest + +from ingot.config.crypto import ( + ConfigError, + _load_or_create_machine_key, + decrypt_secret, + encrypt_secret, +) + + +def test_roundtrip(tmp_path, monkeypatch): + monkeypatch.setattr("ingot.config.crypto.KEY_FILE", tmp_path / ".key") + plaintext = "super-secret-api-key" + ciphertext = encrypt_secret(plaintext) + assert decrypt_secret(ciphertext) == plaintext + + +def test_encrypt_nondeterministic(tmp_path, monkeypatch): + monkeypatch.setattr("ingot.config.crypto.KEY_FILE", tmp_path / ".key") + c1 = encrypt_secret("hello") + c2 = encrypt_secret("hello") + assert c1 != c2 # Fernet uses random IV + + +def test_decrypt_bad_ciphertext_raises(tmp_path, monkeypatch): + monkeypatch.setattr("ingot.config.crypto.KEY_FILE", tmp_path / ".key") + with pytest.raises(ConfigError): + decrypt_secret("not-a-valid-fernet-token") + + +def test_machine_key_created_with_correct_permissions(tmp_path, monkeypatch): + key_path = tmp_path / ".key" + monkeypatch.setattr("ingot.config.crypto.KEY_FILE", key_path) + _load_or_create_machine_key() + assert key_path.exists() + mode = stat.S_IMODE(key_path.stat().st_mode) + assert mode == 0o600 + + +def test_machine_key_idempotent(tmp_path, monkeypatch): + key_path = tmp_path / ".key" + monkeypatch.setattr("ingot.config.crypto.KEY_FILE", key_path) + k1 = _load_or_create_machine_key() + k2 = _load_or_create_machine_key() + assert k1 == k2 diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py new file mode 100644 index 0000000..fcb21b1 --- /dev/null +++ b/tests/test_config_manager.py @@ -0,0 +1,59 @@ +"""Tests for ingot.config.manager.ConfigManager.""" +import json + +import pytest + +from ingot.config.manager import ConfigManager, _ENCRYPTED_PREFIX +from ingot.config.schema import AppConfig + + +@pytest.fixture(autouse=True) +def _patch_key_file(tmp_path, monkeypatch): + monkeypatch.setattr("ingot.config.crypto.KEY_FILE", tmp_path / ".key") + + +def test_load_returns_default_when_missing(tmp_config_dir): + cm = ConfigManager(base_dir=tmp_config_dir) + cfg = cm.load() + assert isinstance(cfg, AppConfig) + assert cfg.smtp.host == "smtp.gmail.com" + + +def test_save_and_load_roundtrip(tmp_config_dir): + cm = ConfigManager(base_dir=tmp_config_dir) + cfg = AppConfig(mailing_address="123 Main St") + cfg.smtp.password = "my-password" + cm.save(cfg) + loaded = cm.load() + assert loaded.mailing_address == "123 Main St" + assert loaded.smtp.password == "my-password" + + +def test_secrets_are_encrypted_on_disk(tmp_config_dir): + cm = ConfigManager(base_dir=tmp_config_dir) + cfg = AppConfig() + cfg.smtp.password = "plaintext-password" + cm.save(cfg) + raw = json.loads((tmp_config_dir / "config.json").read_text()) + assert raw["smtp"]["password"].startswith(_ENCRYPTED_PREFIX) + + +def test_ensure_dirs_creates_subdirectories(tmp_config_dir): + cm = ConfigManager(base_dir=tmp_config_dir) + cm.ensure_dirs() + for sub in ["logs", "resume", "venues"]: + assert (tmp_config_dir / sub).is_dir() + + +def test_get_db_path(tmp_config_dir): + cm = ConfigManager(base_dir=tmp_config_dir) + assert cm.get_db_path() == tmp_config_dir / "outreach.db" + + +def test_empty_secret_not_encrypted(tmp_config_dir): + """Empty string secrets should not be encrypted (skipped).""" + cm = ConfigManager(base_dir=tmp_config_dir) + cfg = AppConfig() # all secrets are empty strings + cm.save(cfg) + raw = json.loads((tmp_config_dir / "config.json").read_text()) + assert raw["smtp"]["password"] == "" # not encrypted diff --git a/tests/test_config_schema.py b/tests/test_config_schema.py new file mode 100644 index 0000000..03465ac --- /dev/null +++ b/tests/test_config_schema.py @@ -0,0 +1,31 @@ +"""Tests for ingot.config.schema.AppConfig.""" +from ingot.config.schema import AgentConfig, AppConfig, ImapConfig, SmtpConfig + +EXPECTED_AGENTS = {"orchestrator", "scout", "research", "matcher", "writer", "outreach", "analyst"} + + +def test_default_agents(): + cfg = AppConfig() + assert set(cfg.agents.keys()) == EXPECTED_AGENTS + + +def test_agent_default_model(): + cfg = AgentConfig() + assert cfg.model == "ollama/llama3.1" + + +def test_smtp_default_port(): + assert SmtpConfig().port == 587 + + +def test_imap_default_port(): + assert ImapConfig().port == 993 + + +def test_appconfig_dump_validate_roundtrip(): + cfg = AppConfig(mailing_address="456 Oak Ave") + cfg.smtp.username = "user@example.com" + dumped = cfg.model_dump() + restored = AppConfig.model_validate(dumped) + assert restored.mailing_address == "456 Oak Ave" + assert restored.smtp.username == "user@example.com" diff --git a/tests/test_db_engine.py b/tests/test_db_engine.py new file mode 100644 index 0000000..a99ffe0 --- /dev/null +++ b/tests/test_db_engine.py @@ -0,0 +1,28 @@ +"""Tests for ingot.db.engine functions.""" +from pathlib import Path + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from ingot.db.engine import _get_database_url, init_db, create_engine + + +def test_get_database_url_with_base_dir(tmp_path): + url = _get_database_url(base_dir=tmp_path) + assert "outreach.db" in url + assert tmp_path.as_posix() in url + + +async def test_init_db_creates_tables(in_memory_engine): + # Tables were created in conftest; verify 'lead' table exists + async with in_memory_engine.connect() as conn: + result = await conn.execute( + text("SELECT name FROM sqlite_master WHERE type='table' AND name='lead'") + ) + row = result.fetchone() + assert row is not None, "lead table not created" + + +async def test_session_yields_async_session(async_session): + assert isinstance(async_session, AsyncSession) diff --git a/tests/test_db_repositories.py b/tests/test_db_repositories.py new file mode 100644 index 0000000..c86b8fa --- /dev/null +++ b/tests/test_db_repositories.py @@ -0,0 +1,53 @@ +"""Tests for ingot.db.repositories.base.BaseRepository using Lead as the test model.""" +from ingot.db.repositories.base import BaseRepository +from ingot.db.models import Lead + + +async def test_add_returns_with_id(async_session): + repo = BaseRepository(async_session, Lead) + lead = Lead(company_name="Acme Corp") + result = await repo.add(lead) + assert result.id is not None + assert result.company_name == "Acme Corp" + + +async def test_get_existing(async_session): + repo = BaseRepository(async_session, Lead) + added = await repo.add(Lead(company_name="Beta Inc")) + fetched = await repo.get(added.id) + assert fetched is not None + assert fetched.company_name == "Beta Inc" + + +async def test_get_missing_returns_none(async_session): + repo = BaseRepository(async_session, Lead) + assert await repo.get(99999) is None + + +async def test_list_all(async_session): + repo = BaseRepository(async_session, Lead) + await repo.add(Lead(company_name="A")) + await repo.add(Lead(company_name="B")) + results = await repo.list() + assert len(results) >= 2 + + +async def test_list_limit_offset(async_session): + repo = BaseRepository(async_session, Lead) + for i in range(5): + await repo.add(Lead(company_name=f"Co{i}")) + page = await repo.list(limit=2, offset=0) + assert len(page) == 2 + + +async def test_delete_existing(async_session): + repo = BaseRepository(async_session, Lead) + added = await repo.add(Lead(company_name="DeleteMe")) + deleted = await repo.delete(added.id) + assert deleted is True + assert await repo.get(added.id) is None + + +async def test_delete_missing(async_session): + repo = BaseRepository(async_session, Lead) + assert await repo.delete(99999) is False diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py new file mode 100644 index 0000000..1fd522e --- /dev/null +++ b/tests/test_dispatcher.py @@ -0,0 +1,48 @@ +"""Tests for ingot.dispatcher.AsyncTaskDispatcher.""" +from ingot.dispatcher import AsyncTaskDispatcher + + +async def _succeed(value): + return value + + +async def _fail(): + raise ValueError("intentional failure") + + +async def test_empty_queue(): + d = AsyncTaskDispatcher() + results = await d.run_all() + assert results == [] + + +async def test_single_task(): + d = AsyncTaskDispatcher() + d.enqueue("task1", _succeed, value=42) + results = await d.run_all() + assert len(results) == 1 + assert results[0].success + assert results[0].result == 42 + assert results[0].task_name == "task1" + + +async def test_multiple_tasks_all_complete(): + d = AsyncTaskDispatcher(max_workers=3) + for i in range(5): + d.enqueue(f"task{i}", _succeed, value=i) + results = await d.run_all() + assert len(results) == 5 + assert all(r.success for r in results) + + +async def test_failing_task_isolated(): + d = AsyncTaskDispatcher(max_workers=2) + d.enqueue("good", _succeed, value="ok") + d.enqueue("bad", _fail) + results = await d.run_all() + assert len(results) == 2 + successes = [r for r in results if r.success] + failures = [r for r in results if not r.success] + assert len(successes) == 1 + assert len(failures) == 1 + assert isinstance(failures[0].error, ValueError) diff --git a/tests/test_http_client.py b/tests/test_http_client.py new file mode 100644 index 0000000..b0440e9 --- /dev/null +++ b/tests/test_http_client.py @@ -0,0 +1,38 @@ +"""Tests for ingot.http_client singleton.""" +import httpx + +from ingot.http_client import HttpClientConfig, close_http_client, get_http_client + + +async def test_singleton_returns_same_instance(): + await close_http_client() + c1 = get_http_client() + c2 = get_http_client() + assert c1 is c2 + await close_http_client() + + +async def test_close_resets_singleton(): + await close_http_client() + c1 = get_http_client() + await close_http_client() + c2 = get_http_client() + assert c1 is not c2 + await close_http_client() + + +async def test_custom_config_applied(): + await close_http_client() + cfg = HttpClientConfig(max_connections=5, timeout_seconds=10.0) + client = get_http_client(config=cfg) + assert client.timeout.read == 10.0 + await close_http_client() + + +async def test_close_resets_config_snapshot(): + """close_http_client() must clear _config_snapshot for clean test isolation.""" + from ingot.http_client import _config_snapshot as snap_before + get_http_client(HttpClientConfig(timeout_seconds=99.0)) + await close_http_client() + from ingot.http_client import _config_snapshot as snap_after + assert snap_after is None diff --git a/tests/test_llm_client.py b/tests/test_llm_client.py new file mode 100644 index 0000000..51691a0 --- /dev/null +++ b/tests/test_llm_client.py @@ -0,0 +1,125 @@ +"""Tests for ingot.llm.client.LLMClient.complete().""" +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from pydantic import BaseModel + +from ingot.agents.exceptions import LLMError, LLMValidationError +from ingot.llm.client import LLMClient + + +class ResponseSchema(BaseModel): + name: str + score: int + + +def _make_response(content=None, tool_args=None): + """Build a mock acompletion response.""" + msg = MagicMock() + msg.content = content + if tool_args: + tool_call = MagicMock() + tool_call.function.arguments = tool_args + msg.tool_calls = [tool_call] + else: + msg.tool_calls = None + choice = MagicMock() + choice.message = msg + choice.finish_reason = "stop" + response = MagicMock() + response.choices = [choice] + return response + + +@pytest.fixture +def client(): + return LLMClient(model="ollama/llama3.1", max_retries=1) + + +async def test_path1_tool_call(client): + args = json.dumps({"name": "Acme", "score": 9}) + with patch("ingot.llm.client.acompletion", new_callable=AsyncMock) as mock_ac: + mock_ac.return_value = _make_response(tool_args=args) + result = await client.complete( + messages=[{"role": "user", "content": "test"}], + response_schema=ResponseSchema, + tools=[{"type": "function", "function": {"name": "fn"}}], + ) + assert result.name == "Acme" + assert result.score == 9 + + +async def test_path2_content_json(client): + content = '{"name": "Beta", "score": 7}' + with patch("ingot.llm.client.acompletion", new_callable=AsyncMock) as mock_ac: + mock_ac.return_value = _make_response(content=content) + result = await client.complete( + messages=[{"role": "user", "content": "test"}], + response_schema=ResponseSchema, + ) + assert result.name == "Beta" + assert result.score == 7 + + +async def test_path3_xml_fallback(client): + content = "Gamma Corp5" + with patch("ingot.llm.client.acompletion", new_callable=AsyncMock) as mock_ac: + mock_ac.return_value = _make_response(content=content) + result = await client.complete( + messages=[{"role": "user", "content": "test"}], + response_schema=ResponseSchema, + ) + assert result.name == "Gamma Corp" + assert result.score == 5 + + +async def test_backend_error_raises_llm_error(client): + with patch("ingot.llm.client.acompletion", new_callable=AsyncMock) as mock_ac: + mock_ac.side_effect = RuntimeError("connection refused") + with pytest.raises(LLMError): + await client.complete( + messages=[{"role": "user", "content": "test"}], + response_schema=ResponseSchema, + ) + + +async def test_unparseable_raises_llm_validation_error(client): + content = "not json, not xml" + with patch("ingot.llm.client.acompletion", new_callable=AsyncMock) as mock_ac: + mock_ac.return_value = _make_response(content=content) + with pytest.raises(LLMValidationError): + await client.complete( + messages=[{"role": "user", "content": "test"}], + response_schema=ResponseSchema, + ) + + +async def test_xml_fallback_disabled_raises_llm_validation_error(client): + """When use_xml_fallback=False, XML content must raise LLMValidationError.""" + content = "Acme5" + with patch("ingot.llm.client.acompletion", new_callable=AsyncMock) as mock_ac: + mock_ac.return_value = _make_response(content=content) + with pytest.raises(LLMValidationError): + await client.complete( + messages=[{"role": "user", "content": "test"}], + response_schema=ResponseSchema, + use_xml_fallback=False, + ) + + +async def test_tool_call_invalid_json_falls_back_to_content(client): + """Invalid tool-call JSON should fall through to the content JSON path.""" + content = '{"name": "Delta", "score": 3}' + with patch("ingot.llm.client.acompletion", new_callable=AsyncMock) as mock_ac: + mock_ac.return_value = _make_response( + content=content, + tool_args="not-valid-json{{{", # Malformed JSON triggers fallback + ) + result = await client.complete( + messages=[{"role": "user", "content": "test"}], + response_schema=ResponseSchema, + tools=[{"type": "function", "function": {"name": "fn"}}], + ) + assert result.name == "Delta" + assert result.score == 3 diff --git a/tests/test_llm_fallback.py b/tests/test_llm_fallback.py new file mode 100644 index 0000000..1f29812 --- /dev/null +++ b/tests/test_llm_fallback.py @@ -0,0 +1,55 @@ +"""Tests for ingot.llm.fallback.xml_extract.""" +from typing import Optional + +import pytest +from pydantic import BaseModel + +from ingot.agents.exceptions import LLMValidationError +from ingot.llm.fallback import xml_extract + + +class SimpleSchema(BaseModel): + company_name: str + industry: str + + +class ListSchema(BaseModel): + skills: list[str] + + +class OptionalListSchema(BaseModel): + tags: Optional[list[str]] = None + + +class RequiredSchema(BaseModel): + required_field: str # no default — must be present + + +def test_flat_schema_extraction(): + content = "Acme CorpSaaS" + result = xml_extract(content, SimpleSchema) + assert result.company_name == "Acme Corp" + assert result.industry == "SaaS" + + +def test_list_field_newline_split(): + content = "Python\nGo\nRust" + result = xml_extract(content, ListSchema) + assert result.skills == ["Python", "Go", "Rust"] + + +def test_optional_list_unwrapped(): + content = "alpha\nbeta" + result = xml_extract(content, OptionalListSchema) + assert result.tags == ["alpha", "beta"] + + +def test_missing_required_field_raises(): + content = "SaaS" # company_name missing + with pytest.raises(LLMValidationError): + xml_extract(content, SimpleSchema) + + +def test_empty_content_raises_for_required_schema(): + with pytest.raises(LLMValidationError): + xml_extract("", RequiredSchema) diff --git a/tests/test_llm_schemas.py b/tests/test_llm_schemas.py new file mode 100644 index 0000000..75d629c --- /dev/null +++ b/tests/test_llm_schemas.py @@ -0,0 +1,50 @@ +"""Tests for ingot.llm.schemas — internal LLM request/response envelopes.""" +from ingot.llm.schemas import LLMMessage, LLMRequest, LLMResponse + + +def test_llm_message_construction(): + msg = LLMMessage(role="user", content="hello") + assert msg.role == "user" + assert msg.content == "hello" + + +def test_llm_request_construction(): + req = LLMRequest( + model="ollama/llama3.1", + messages=[LLMMessage(role="user", content="test")], + ) + assert req.model == "ollama/llama3.1" + assert req.tools is None + + +def test_llm_request_with_tools(): + req = LLMRequest( + model="claude-3-5", + messages=[LLMMessage(role="system", content="sys")], + tools=[{"type": "function", "function": {"name": "fn"}}], + ) + assert len(req.tools) == 1 + + +def test_llm_response_construction(): + resp = LLMResponse(content='{"name": "Acme"}', finish_reason="stop") + assert resp.used_xml_fallback is False + assert resp.tool_call_args is None + + +def test_llm_response_with_tool_call(): + resp = LLMResponse( + content="", + tool_call_args='{"name": "Beta"}', + finish_reason="tool_calls", + used_xml_fallback=False, + ) + assert resp.tool_call_args is not None + + +def test_llm_message_roundtrip(): + msg = LLMMessage(role="assistant", content="here is the result") + dumped = msg.model_dump() + restored = LLMMessage.model_validate(dumped) + assert restored.role == "assistant" + assert restored.content == "here is the result" diff --git a/tests/test_logging_config.py b/tests/test_logging_config.py new file mode 100644 index 0000000..a0475b7 --- /dev/null +++ b/tests/test_logging_config.py @@ -0,0 +1,44 @@ +"""Tests for ingot.logging_config.""" +import logging + +import structlog + +from ingot.logging_config import configure_logging, get_logger + + +def test_get_logger_returns_structlog_logger(): + logger = get_logger("ingot.test") + # structlog BoundLogger wraps stdlib logger — check it's callable + assert hasattr(logger, "info") + assert hasattr(logger, "warning") + assert hasattr(logger, "error") + + +def test_configure_logging_verbosity_0_sets_warning(tmp_path): + """Verbosity 0 should set root log level to WARNING.""" + configure_logging(tmp_path, verbosity=0) + assert logging.getLogger().level == logging.WARNING + + +def test_configure_logging_verbosity_1_sets_info(tmp_path): + """Verbosity 1 (-v flag) should set root log level to INFO.""" + configure_logging(tmp_path, verbosity=1) + assert logging.getLogger().level == logging.INFO + + +def test_configure_logging_verbosity_2_sets_debug(tmp_path): + """Verbosity 2 (-vv flag) should set root log level to DEBUG.""" + configure_logging(tmp_path, verbosity=2) + assert logging.getLogger().level == logging.DEBUG + + +def test_configure_logging_creates_log_dir(tmp_path): + """configure_logging must create the logs/ subdirectory.""" + configure_logging(tmp_path, verbosity=0) + assert (tmp_path / "logs").is_dir() + + +def test_configure_logging_high_verbosity_defaults_to_debug(tmp_path): + """Verbosity values beyond 2 should default to DEBUG.""" + configure_logging(tmp_path, verbosity=99) + assert logging.getLogger().level == logging.DEBUG diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py new file mode 100644 index 0000000..f682ad5 --- /dev/null +++ b/tests/test_orchestrator.py @@ -0,0 +1,104 @@ +"""Tests for ingot.agents.orchestrator.Orchestrator. + +These tests verify the routing and error-wrapping contracts that make +Orchestrator the single coordination point in the pipeline: +- run() delegates to the correct registered agent +- run() wraps non-AgentError exceptions in AgentError (typed error boundary) +- run_step() delegates to the correct agent's run_step() +- run_step() wraps exceptions in AgentError +- list_available_agents() reflects what's in the registry +- list_steps() returns the agent's declared STEPS +""" +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from ingot.agents.base import AgentDeps, AgentRunResult, StepResult +from ingot.agents.exceptions import AgentError +from ingot.agents.orchestrator import Orchestrator +from ingot.llm.client import LLMClient + + +def make_deps() -> AgentDeps: + return AgentDeps( + llm_client=MagicMock(spec=LLMClient), + session=MagicMock(), + http_client=MagicMock(spec=httpx.AsyncClient), + ) + + +@pytest.fixture +def orc() -> Orchestrator: + return Orchestrator(deps=make_deps()) + + +async def test_run_delegates_to_registered_agent(orc): + """run() must pass through to the named agent in the registry.""" + expected = AgentRunResult(agent_name="scout", success=True, steps=[]) + mock_agent = MagicMock() + mock_agent.run = AsyncMock(return_value=expected) + + with patch("ingot.agents.orchestrator.get_agent", return_value=mock_agent): + result = await orc.run("scout", prompt="find leads") + + mock_agent.run.assert_awaited_once() + assert result is expected + + +async def test_run_wraps_exception_in_agent_error(orc): + """Unexpected exceptions from agents must be wrapped as AgentError.""" + mock_agent = MagicMock() + mock_agent.run = AsyncMock(side_effect=RuntimeError("agent blew up")) + + with patch("ingot.agents.orchestrator.get_agent", return_value=mock_agent): + with pytest.raises(AgentError) as exc_info: + await orc.run("scout") + + assert "scout" in str(exc_info.value) + + +async def test_run_step_delegates_to_agent(orc): + """run_step() must delegate to the named agent's run_step().""" + expected = StepResult(step="discover", success=True) + mock_agent = MagicMock() + mock_agent.run_step = AsyncMock(return_value=expected) + + with patch("ingot.agents.orchestrator.get_agent", return_value=mock_agent): + result = await orc.run_step("scout", "discover") + + mock_agent.run_step.assert_awaited_once_with("discover", orc.deps) + assert result is expected + + +async def test_run_step_wraps_exception_in_agent_error(orc): + """Step-level exceptions must be wrapped as AgentError.""" + mock_agent = MagicMock() + mock_agent.run_step = AsyncMock(side_effect=ValueError("bad step state")) + + with patch("ingot.agents.orchestrator.get_agent", return_value=mock_agent): + with pytest.raises(AgentError) as exc_info: + await orc.run_step("scout", "discover") + + assert "discover" in str(exc_info.value) + + +def test_list_available_agents_uses_registry(orc): + """list_available_agents() must reflect the registry contents.""" + agents = orc.list_available_agents() + # All 6 non-orchestrator agents should be registered + for name in ["scout", "research", "matcher", "writer", "outreach", "analyst"]: + assert name in agents + + +def test_list_steps_returns_agent_steps(orc): + """list_steps() must return the STEPS declared by the named agent.""" + steps = orc.list_steps("scout") + assert steps == ["discover", "deduplicate", "score"] + + +def test_list_steps_matcher(orc): + steps = orc.list_steps("matcher") + assert steps == ["load_profile", "compare", "score"]