diff --git a/.env.example b/.env.example index 0a2b4ae..bec4bf6 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,13 @@ TANGO_API_KEY= # Only required for examples/ (e.g. the opportunities agent). Notebooks don't need it. ANTHROPIC_API_KEY= +# Optional. Used by examples/opportunities-agent/local_agent.py to point at a +# local OpenAI-compatible server (Ollama, llama.cpp, vLLM, LM Studio, etc.). +# Defaults: qwen2.5:14b @ http://localhost:11434/v1 (Ollama). +LOCAL_MODEL= +LOCAL_BASE_URL= +LOCAL_API_KEY= + # Only required for examples/webhook-receiver/server.py. # Printed when you create the endpoint via examples/webhook-receiver/register.py. TANGO_WEBHOOK_SECRET= diff --git a/examples/opportunities-agent/README.md b/examples/opportunities-agent/README.md index 71be873..71507b0 100644 --- a/examples/opportunities-agent/README.md +++ b/examples/opportunities-agent/README.md @@ -22,6 +22,20 @@ uv run python examples/opportunities-agent/agent.py "your question here" Needs `TANGO_API_KEY` and `ANTHROPIC_API_KEY` in `.env`. (`.env.example` lists both.) +### Or run it against a local model + +`local_agent.py` is the same loop, swapped onto any OpenAI-compatible endpoint — Ollama, llama.cpp, vLLM, LM Studio. With Ollama: + +```bash +ollama pull qwen2.5:14b # or qwen2.5:32b, llama3.3:70b, gpt-oss:20b +just local-agent # default question +just local-agent "your question here" +``` + +Override the model or endpoint via env (`LOCAL_MODEL`, `LOCAL_BASE_URL`). Only `TANGO_API_KEY` is required; no Anthropic key needed. `diff agent.py local_agent.py` is the cheapest way to see exactly where the Anthropic and OpenAI tool-use protocols differ. + +Expect rougher reasoning than Sonnet — smaller models loop more, occasionally mangle argument names, and need a tighter `MAX_TURNS`. Good enough for the simple incumbent-radar question; thin out fast on the harder ones. + ### Questions that work well vs. questions that don't The agent shines when the question is narrow enough that one or two contract searches can plausibly identify an incumbent. Examples: diff --git a/examples/opportunities-agent/agent.py b/examples/opportunities-agent/agent.py index 4505f33..5037b0d 100644 --- a/examples/opportunities-agent/agent.py +++ b/examples/opportunities-agent/agent.py @@ -51,7 +51,7 @@ - Keyword `search` is vector-backed (semantic). Short phrases (1-2 words like "website modernization", "help desk", "court reporter") work better than long ones — extra words dilute the vector and can drop the strongest match out of the top results. - For finding incumbents, prefer a specific agency over a NAICS-only contract search. NAICS-only searches across all of government are noisy. -When you have enough information, give a tight, action-oriented summary. Skip the field-by-field recap. Be honest if data is thin or a search returned nothing useful — say so and suggest what to try next. +When you have enough information, give a tight, action-oriented summary. Skip the field-by-field recap. Always include the SAM.gov link for the opportunity (the `sam_url` field from the tool result). Be honest if data is thin or a search returned nothing useful — say so and suggest what to try next. Don't keep retrying searches that come back empty. Two empty searches on a question is a signal to stop, deliver what you have, and tell the user what was missing — not to keep guessing keyword variations.""" @@ -165,27 +165,33 @@ # decisions — what to search for, which result to drill into — happen in the # model, not here. +# Field list passed as Tango's `shape` parameter. Tango's default shape strips +# everything except 5 fields, including the `sam_url` that we want the model to +# surface in the brief. (Tango computes `sam_url` itself, using the *latest* +# notice id with hyphens stripped — which is what SAM.gov actually accepts — +# so we never want to construct it client-side.) +OPP_SHAPE = ( + "opportunity_id,title,solicitation_number,naics_code,psc_code,set_aside," + "response_deadline,first_notice_date,active,place_of_performance,office,sam_url" +) + +# Details get the same fields plus description + primary contact for the brief. +OPP_DETAILS_SHAPE = OPP_SHAPE + ",description,primary_contact" + + def _trim_opportunity(opp: dict[str, Any]) -> dict[str, Any]: - """Keep just the fields useful for triage. Full record is available via details.""" - return { - k: opp.get(k) - for k in ( - "opportunity_id", "title", "notice_type", "agency", "naics", - "psc", "set_aside", "response_deadline", "first_notice_date", - "active", "place_of_performance", - ) - if opp.get(k) is not None - } + """Drop null fields so the JSON we hand the model stays compact.""" + return {k: v for k, v in opp.items() if v is not None} def run_tool(tango: TangoClient, name: str, args: dict[str, Any]) -> Any: if name == "search_opportunities": limit = min(int(args.pop("limit", 5)), 25) - page = tango.list_opportunities(limit=limit, **args) + page = tango.list_opportunities(limit=limit, shape=OPP_SHAPE, **args) return {"count": page.count, "results": [_trim_opportunity(r) for r in page.results]} if name == "get_opportunity_details": - return tango.get_opportunity(args["opportunity_id"]) + return tango.get_opportunity(args["opportunity_id"], shape=OPP_DETAILS_SHAPE) if name == "search_contracts": limit = min(int(args.pop("limit", 5)), 25) diff --git a/examples/opportunities-agent/local_agent.py b/examples/opportunities-agent/local_agent.py new file mode 100644 index 0000000..ede30cf --- /dev/null +++ b/examples/opportunities-agent/local_agent.py @@ -0,0 +1,179 @@ +""" +The local-model twin of agent.py — same Tango tools, same loop, swapped LLM. + +Talks to any OpenAI-compatible server. Defaults to Ollama on localhost, so: + + ollama pull qwen2.5:14b # or qwen2.5:32b, llama3.3:70b, gpt-oss:20b + just local-agent # uses default question + just local-agent "your question" + +Or directly: + + uv run python examples/opportunities-agent/local_agent.py "your question" + +Override the model / endpoint via env: + + LOCAL_MODEL=qwen2.5:32b LOCAL_BASE_URL=http://localhost:11434/v1 just local-agent + +Requires TANGO_API_KEY. No ANTHROPIC_API_KEY needed. + +The two scripts are intentionally mirror images so you can diff them and see +exactly where the Anthropic and OpenAI tool-use protocols differ. +""" + +from __future__ import annotations + +import json +import os +import sys +from datetime import date +from typing import Any + +from openai import OpenAI +from tango import TangoClient + +# Import the tools + implementations from the Anthropic version. The tool catalog +# itself is identical — only the wire format around it changes. +from agent import TOOLS, DEFAULT_QUESTION, run_tool + +MODEL = os.environ.get("LOCAL_MODEL", "qwen2.5:14b") +BASE_URL = os.environ.get("LOCAL_BASE_URL", "http://localhost:11434/v1") +API_KEY = os.environ.get("LOCAL_API_KEY", "ollama") # Ollama ignores; client requires non-empty. +MAX_TURNS = 6 # tighter than the Anthropic version — local models loop more. + + +# A separate, hardened system prompt for smaller local models. +# +# The Anthropic version (agent.py:SYSTEM_PROMPT) phrases stop conditions as soft +# guidance ("two empty searches is a signal to stop") and trusts the model to +# self-regulate. Sonnet does. A 7B–30B local model does not — it will burn +# every available turn keyword-grinding until MAX_TURNS cuts it off, never +# producing a final brief. +# +# This prompt: +# - Front-loads the stop conditions as hard rules, numbered. +# - Caps each phase of the pipeline (≤2 opp searches, ≤1 details, ≤1 contract search). +# - Requires a final brief on the last allowed turn even if data is thin. +# - Forbids near-duplicate searches (the actual failure mode observed locally). +LOCAL_SYSTEM_PROMPT = """You triage federal contracting opportunities from SAM.gov via the Tango API. + +You have three tools: +- search_opportunities: find candidate opportunities. +- get_opportunity_details: pull the full record for one opportunity. +- search_contracts: find recent awarded contracts (to spot likely incumbents). + +# Hard rules — follow exactly: + +1. Pipeline: search_opportunities → get_opportunity_details (on the best hit) → search_contracts (for the incumbent) → write a final brief. In that order. +2. At most TWO search_opportunities calls. If both come back empty or irrelevant, stop searching and tell the user what was missing. +3. At most ONE get_opportunity_details call. Pick the single most promising opportunity from the search results. +4. At most ONE search_contracts call. Use the agency from the opportunity you picked. +5. NEVER make two searches with only minor keyword variations ("website" then "web app" then "web modernization"). One keyword attempt per search call. If a search returns nothing useful, change strategy — drop a filter, switch agency — don't just reword. +6. After your contract search (or after step 2 if no opportunity panned out), you MUST write the final brief. Do not call any more tools. +7. Do not think out loud. Either call a tool or write the final brief. Nothing else. + +# Filter guidance: + +- For real bid-able opportunities: notice_type='o' (Solicitation) or 'k' (Combined Synopsis/Solicitation). +- "active" alone does not mean the deadline is in the future. Always pair with response_deadline_after set to today. +- Keyword search is vector-backed. Use 1–2 word phrases ("website modernization", "court reporter"). Not long sentences. +- For incumbent search, prefer a specific awarding_agency over NAICS-only. + +# Final brief format: + +Three short sections: **Opportunity**, **Suspected incumbent**, **What to think about**. Under **Opportunity**, always include the SAM.gov link (the `sam_url` field from the tool result). Keep it tight. Be honest if data is thin — say so.""" + + +# --- Translate the tool catalog into OpenAI's function-calling shape ---------- +# Anthropic: {name, description, input_schema} +# OpenAI: {type: "function", function: {name, description, parameters}} + +OPENAI_TOOLS = [ + { + "type": "function", + "function": { + "name": t["name"], + "description": t["description"], + "parameters": t["input_schema"], + }, + } + for t in TOOLS +] + + +# --- The agent loop ------------------------------------------------------------ + +def run(question: str) -> None: + client = OpenAI(base_url=BASE_URL, api_key=API_KEY) + tango = TangoClient() + + messages: list[dict[str, Any]] = [ + { + "role": "system", + "content": f"{LOCAL_SYSTEM_PROMPT}\n\nToday's date is {date.today().isoformat()}.", + }, + {"role": "user", "content": question}, + ] + + print(f"\n> {question}\n[model: {MODEL} @ {BASE_URL}]\n") + + for turn in range(MAX_TURNS): + response = client.chat.completions.create( + model=MODEL, + messages=messages, + tools=OPENAI_TOOLS, + max_tokens=2048, + ) + msg = response.choices[0].message + + if msg.content and msg.content.strip(): + print(msg.content) + + # Local models often set finish_reason="stop" even when they emitted tool + # calls, so trust tool_calls rather than finish_reason. + if not msg.tool_calls: + return + + # Append the assistant turn verbatim so the model sees its own tool calls. + messages.append({ + "role": "assistant", + "content": msg.content or "", + "tool_calls": [ + { + "id": tc.id, + "type": "function", + "function": {"name": tc.function.name, "arguments": tc.function.arguments}, + } + for tc in msg.tool_calls + ], + }) + + for tc in msg.tool_calls: + name = tc.function.name + try: + args = json.loads(tc.function.arguments or "{}") + except json.JSONDecodeError as e: + args = {} + result: Any = {"error": f"could not parse arguments: {e}; raw={tc.function.arguments!r}"} + else: + print(f"\n → {name}({json.dumps(args)})") + try: + result = run_tool(tango, name, dict(args)) + except Exception as e: + result = {"error": f"{type(e).__name__}: {e}"} + + messages.append({ + "role": "tool", + "tool_call_id": tc.id, + "content": json.dumps(result, default=str), + }) + + print(f"\n[stopped: hit MAX_TURNS={MAX_TURNS}]") + + +if __name__ == "__main__": + if not os.environ.get("TANGO_API_KEY"): + sys.exit("missing TANGO_API_KEY — see .env.example") + + question = " ".join(sys.argv[1:]).strip() or DEFAULT_QUESTION + run(question) diff --git a/justfile b/justfile index ea59d71..9494650 100644 --- a/justfile +++ b/justfile @@ -37,7 +37,11 @@ refresh: # Run the opportunities agent example. Pass a question or use the default. agent *question: - uv run python examples/opportunities-agent/agent.py {{question}} + uv run python examples/opportunities-agent/agent.py {{ quote(question) }} + +# Run the same agent against a local OpenAI-compatible model (Ollama by default). +local-agent *question: + uv run python examples/opportunities-agent/local_agent.py {{ quote(question) }} # Run the saved-search watcher once. Pass flags like --seed or --dry-run. watch *flags: diff --git a/pyproject.toml b/pyproject.toml index ab7de3f..d9f52ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dev = [ ] examples = [ "anthropic>=0.40", + "openai>=1.50", "pyyaml>=6.0", "fastapi>=0.115", "uvicorn>=0.30", diff --git a/uv.lock b/uv.lock index ccc6f46..325a3ef 100644 --- a/uv.lock +++ b/uv.lock @@ -1091,6 +1091,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307, upload-time = "2024-02-14T23:35:16.286Z" }, ] +[[package]] +name = "openai" +version = "2.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/a6/5815fe2e2aca74b36c650d1bd43b69827cee568073d0d2d9b6fc5aaac80c/openai-2.41.0.tar.gz", hash = "sha256:db5c362acd6604b84f076abbefa66826ea4b46ecba2954ed866e6a149a1352c0", size = 783525, upload-time = "2026-06-03T22:39:40.719Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/51/d82bb424e8aa372190c5233253a2ceb399a778747d18b42cff487411e663/openai-2.41.0-py3-none-any.whl", hash = "sha256:20cc7952e8501c7e5773dd2ef7be437bae9cb549044902e1041a83a54516e375", size = 1353378, upload-time = "2026-06-03T22:39:38.964Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -1713,6 +1732,7 @@ dev = [ examples = [ { name = "anthropic" }, { name = "fastapi" }, + { name = "openai" }, { name = "pyyaml" }, { name = "uvicorn" }, ] @@ -1732,6 +1752,7 @@ dev = [ examples = [ { name = "anthropic", specifier = ">=0.40" }, { name = "fastapi", specifier = ">=0.115" }, + { name = "openai", specifier = ">=1.50" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "uvicorn", specifier = ">=0.30" }, ] @@ -1796,6 +1817,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/98/0cffe22a224f60c5fb1e3aa0b76f9da2e1ca78b0e9545e3d077c68ce60a7/tornado-6.5.6-cp39-abi3-win_arm64.whl", hash = "sha256:2543597b24a695d72338a9a77818362d72387c03ae173f1f169eadc5c91466ac", size = 449690, upload-time = "2026-05-27T15:35:52.902Z" }, ] +[[package]] +name = "tqdm" +version = "4.68.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/b3/36c8ecf72e8925200671613332db156d84b99b3aee742a41c1938ebb0808/tqdm-4.68.1.tar.gz", hash = "sha256:fc163d96b287bd031e1aa24421ce4411b25559bd0a1be4fe649bdaa4d2c02bf5", size = 171236, upload-time = "2026-06-05T17:23:15.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/aa/218a0eb34de1f753c83e4d0d1c8e7c4cef27f20dcb8342e024f63a80dc86/tqdm-4.68.1-py3-none-any.whl", hash = "sha256:fea4a90e4023f764914569f7802a297277c5ab1a66be5144143e142e1a4031d8", size = 78354, upload-time = "2026-06-05T17:23:13.654Z" }, +] + [[package]] name = "traitlets" version = "5.15.1"