Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
14 changes: 14 additions & 0 deletions examples/opportunities-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
32 changes: 19 additions & 13 deletions examples/opportunities-agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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)
Expand Down
179 changes: 179 additions & 0 deletions examples/opportunities-agent/local_agent.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 5 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dev = [
]
examples = [
"anthropic>=0.40",
"openai>=1.50",
"pyyaml>=6.0",
"fastapi>=0.115",
"uvicorn>=0.30",
Expand Down
33 changes: 33 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading