Skip to content
Closed
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
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,5 @@ gem "marksmith", "~> 0.4.5"
gem "commonmarker", "~> 2.3"

gem "appsignal"

gem "mcp"
6 changes: 6 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,9 @@ GEM
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
json (2.12.2)
json-schema (6.2.0)
addressable (~> 2.8)
bigdecimal (>= 3.1, < 5)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
llhttp-ffi (0.5.1)
Expand All @@ -238,6 +241,8 @@ GEM
marcel (1.0.4)
marksmith (0.4.5)
activesupport
mcp (0.9.1)
json-schema (>= 4.1)
meta-tags (2.22.1)
actionpack (>= 6.0.0, < 8.1)
method_source (1.1.0)
Expand Down Expand Up @@ -456,6 +461,7 @@ DEPENDENCIES
importmap-rails (~> 2.1)
jbuilder
marksmith (~> 0.4.5)
mcp
nokogiri (~> 1.18)
pdf-reader (~> 2.12)
pg (~> 1.1)
Expand Down
7 changes: 5 additions & 2 deletions agent/.env.example
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
# Claude API
ANTHROPIC_API_KEY=sk-ant-...

# Read-only Postgres connection for the agent
# Read-only Postgres connection (only needed for entry processing and batch runs)
AGENT_DATABASE_URL=postgresql://agent_reader:password@localhost:5432/outcome_tracker_development

# Rails API for write operations
# Rails API for write operations and REST reads
RAILS_API_URL=http://localhost:3000
RAILS_API_KEY=agent-secret-key

# Remote MCP server URL (defaults to RAILS_API_URL/mcp)
# MCP_SERVER_URL=https://www.buildcanada.com/mcp

# Agent configuration
AGENT_MODEL=claude-opus-4-6
AGENT_MAX_COMMITMENTS_PER_RUN=100
3 changes: 2 additions & 1 deletion agent/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ Base URL: provided in system prompt. Auth: `Authorization: Bearer <key>` (also i
## Rules

- Do NOT use Read, Glob, Grep, or filesystem tools to explore the Rails codebase. Everything you need is above.
- Use the MCP tools (get_commitment, get_bill, etc.) for reading data.
- Use the remote MCP tools (mcp__tracker__get_commitment, mcp__tracker__list_bills, etc.) for reading tracker data.
- Use the local MCP tools (mcp__agent__get_entry, mcp__agent__list_unprocessed_entries, mcp__agent__fetch_government_page) for entry processing and page fetching.
- Use curl via Bash for ALL write operations.
- Every judgement (assess_criterion, create_commitment_event, update_commitment_status) MUST include a source_url that was previously fetched via fetch_government_page.
- Fetch pages BEFORE referencing them. The fetch auto-registers them as Sources in the DB.
135 changes: 31 additions & 104 deletions agent/src/agent/evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,7 @@
SYSTEM_PROMPT,
WEEKLY_SCAN_PROMPT,
)
from agent.tools.db_read import (
get_bill,
get_bills_for_parliament,
get_commitment,
get_commitment_sources,
get_entry,
list_commitments,
list_unprocessed_entries,
)
from agent.tools.db_read import get_entry, list_unprocessed_entries
from agent.tools.web_search import fetch_government_page
from agent.tools.rails_write import register_source

Expand Down Expand Up @@ -63,58 +55,7 @@ def _tool_error(e: Exception, tool_name: str, args: dict) -> dict[str, Any]:
}


# ── Read-only DB tools (via MCP) ───────────────────────────────────────────

@tool(
"get_commitment",
"Fetch a commitment with its criteria, matches, events, linked bills, departments, and source documents.",
{"commitment_id": int},
annotations=ToolAnnotations(readOnlyHint=True),
)
async def get_commitment_tool(args: dict[str, Any]) -> dict[str, Any]:
_tool_log("get_commitment", f"Loading commitment {args['commitment_id']}")
try:
return _tool_result(get_commitment(args["commitment_id"]), "get_commitment")
except Exception as e:
return _tool_error(e, "get_commitment", args)


@tool(
"list_commitments",
"List commitments with optional filters. Params: status, policy_area, commitment_type, stale_days, limit.",
{
"type": "object",
"properties": {
"status": {"type": "string", "enum": ["not_started", "in_progress", "completed", "broken"]},
"policy_area": {"type": "string", "description": "Policy area slug"},
"commitment_type": {"type": "string"},
"stale_days": {"type": "integer"},
"limit": {"type": "integer"},
},
},
annotations=ToolAnnotations(readOnlyHint=True),
)
async def list_commitments_tool(args: dict[str, Any]) -> dict[str, Any]:
_tool_log("list_commitments", f"filters: {args}")
try:
return _tool_result(list_commitments(**args), "list_commitments")
except Exception as e:
return _tool_error(e, "list_commitments", args)


@tool(
"get_bill",
"Fetch a bill with all stage dates (House/Senate readings, Royal Assent) and linked commitments.",
{"bill_id": int},
annotations=ToolAnnotations(readOnlyHint=True),
)
async def get_bill_tool(args: dict[str, Any]) -> dict[str, Any]:
_tool_log("get_bill", f"Loading bill {args['bill_id']}")
try:
return _tool_result(get_bill(args["bill_id"]), "get_bill")
except Exception as e:
return _tool_error(e, "get_bill", args)

# ── Agent-local tools (not served by the remote MCP server) ────────────────

@tool(
"get_entry",
Expand Down Expand Up @@ -144,35 +85,6 @@ async def list_unprocessed_entries_tool(args: dict[str, Any]) -> dict[str, Any]:
return _tool_error(e, "list_unprocessed_entries", args)


@tool(
"get_commitment_sources",
"Get the source documents (platform, Speech from the Throne, budget) for a commitment. Use this to determine where a commitment originated for the budget evidence rule.",
{"commitment_id": int},
annotations=ToolAnnotations(readOnlyHint=True),
)
async def get_commitment_sources_tool(args: dict[str, Any]) -> dict[str, Any]:
_tool_log("get_commitment_sources", f"commitment {args['commitment_id']}")
try:
return _tool_result(get_commitment_sources(args["commitment_id"]), "get_commitment_sources")
except Exception as e:
return _tool_error(e, "get_commitment_sources", args)


@tool(
"get_bills_for_parliament",
"Get all government bills for a parliament session with their stage dates.",
{"type": "object", "properties": {"parliament_number": {"type": "integer"}}},
annotations=ToolAnnotations(readOnlyHint=True),
)
async def get_bills_for_parliament_tool(args: dict[str, Any]) -> dict[str, Any]:
pn = args.get("parliament_number", 45)
_tool_log("get_bills_for_parliament", f"parliament {pn}")
try:
return _tool_result(get_bills_for_parliament(pn), "get_bills_for_parliament")
except Exception as e:
return _tool_error(e, "get_bills_for_parliament", args)


@tool(
"fetch_government_page",
"Fetch and parse content from an official Canadian government webpage (*.canada.ca / *.gc.ca only). "
Expand Down Expand Up @@ -225,27 +137,22 @@ async def fetch_government_page_tool(args: dict[str, Any]) -> dict[str, Any]:
return _tool_error(e, "fetch_government_page", args)


# ── MCP Server (read tools + fetch only) ───────────────────────────────────
# ── MCP Servers ────────────────────────────────────────────────────────────

ALL_TOOLS = [
get_commitment_tool,
list_commitments_tool,
get_bill_tool,
# Agent-local tools that need direct DB access or are side-effecting.
# Read-only tracker tools are served by the remote MCP server (POST /mcp).
LOCAL_TOOLS = [
get_entry_tool,
list_unprocessed_entries_tool,
get_commitment_sources_tool,
get_bills_for_parliament_tool,
fetch_government_page_tool,
]

tracker_server = create_sdk_mcp_server(
name="tracker",
agent_server = create_sdk_mcp_server(
name="agent",
version="1.0.0",
tools=ALL_TOOLS,
tools=LOCAL_TOOLS,
)

ALLOWED_TOOLS = [f"mcp__tracker__{t.name}" for t in ALL_TOOLS] + ["Bash", "WebSearch"]


# ── Agent runner ────────────────────────────────────────────────────────────

Expand All @@ -263,11 +170,31 @@ def _build_options() -> ClaudeAgentOptions:

model = os.environ.get("AGENT_MODEL", "claude-sonnet-4-6")

# Remote MCP server for read-only tracker tools (commitments, departments,
# bills, promises, ministers, feed items, dashboard, burndown).
# Agent-local MCP server for entry tools and fetch_government_page.
tracker_url = os.environ.get("MCP_SERVER_URL", f"{rails_url}/mcp")

remote_tools = [
"mcp__tracker__list_policy_areas",
"mcp__tracker__list_commitments", "mcp__tracker__get_commitment",
"mcp__tracker__list_departments", "mcp__tracker__get_department",
"mcp__tracker__list_promises", "mcp__tracker__get_promise",
"mcp__tracker__list_bills", "mcp__tracker__get_bill",
"mcp__tracker__list_ministers", "mcp__tracker__list_activity",
"mcp__tracker__get_commitment_summary", "mcp__tracker__get_commitment_progress",
]
local_tools = [f"mcp__agent__{t.name}" for t in LOCAL_TOOLS]
allowed_tools = remote_tools + local_tools + ["Bash", "WebSearch"]

return ClaudeAgentOptions(
model=model,
system_prompt=SYSTEM_PROMPT + api_context,
mcp_servers={"tracker": tracker_server},
allowed_tools=ALLOWED_TOOLS,
mcp_servers={
"tracker": {"type": "url", "url": tracker_url},
"agent": agent_server,
},
allowed_tools=allowed_tools,
permission_mode="bypassPermissions",
cwd=str(pathlib.Path(__file__).resolve().parent.parent.parent), # agent/ dir where CLAUDE.md lives
setting_sources=["project"],
Expand Down
20 changes: 17 additions & 3 deletions agent/src/agent/main.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
"""CLI entry point for the commitment evaluation agent."""

import sys
import os
import time
import uuid

import click
import httpx

from agent.evaluator import (
evaluate_commitment,
process_bill_change,
process_entry,
weekly_scan_commitment,
)
from agent.tools.db_read import list_commitments


def _fetch_commitments(status=None, policy_area=None, limit=100):
"""Fetch commitments from the Rails REST API, oldest-assessed first."""
rails_url = os.environ.get("RAILS_API_URL", "http://localhost:3000")
params = {"per_page": limit, "sort": "last_assessed_at", "direction": "asc"}
if status:
params["status"] = status
if policy_area:
params["policy_area"] = policy_area

resp = httpx.get(f"{rails_url}/commitments", params=params, timeout=30.0)
resp.raise_for_status()
return resp.json().get("commitments", [])


@click.group()
Expand Down Expand Up @@ -70,7 +84,7 @@ def scan_all(limit: int, status: str | None, policy_area: str | None, as_of: str
click.echo("Starting weekly scan...")
run_id = str(uuid.uuid4())

commitments = list_commitments(
commitments = _fetch_commitments(
status=status,
policy_area=policy_area,
limit=limit,
Expand Down
Loading