Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ CLAUDE_TIMEOUT=60
PHI_DETECTION_ENABLED=true
CLOUD_MONTHLY_BUDGET_USD=500

# Agent copilot provider (Studies/Publish/Abby on the Claude Agent SDK).
# EE = Anthropic cloud (default); CE = a local model via the claude-router proxy.
# Defaults below preserve EE behavior — set AGENT_PROVIDER=local and run the
# sidecar (docker compose --profile ce up -d claude-router python-ai) for CE.
# Requires Ollama to have AGENT_LOCAL_MODEL pulled. Keep actions disabled until
# the chosen local model proves reliable tool-calling.
AGENT_PROVIDER=anthropic
AGENT_LOCAL_BASE_URL=http://claude-router:8787
AGENT_LOCAL_MODEL=qwen2.5-coder:32b
AGENT_LOCAL_AUTH_TOKEN=local
AGENT_LOCAL_ACTIONS_ENABLED=false
AGENT_LOCAL_EFFORT=medium

# FinnGen (SP1 foundation)
FINNGEN_PG_RO_PASSWORD=replace_me_with_strong_password
FINNGEN_PG_RW_PASSWORD=replace_me_with_strong_password
Expand Down
5 changes: 5 additions & 0 deletions ai/app/agents/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ class AgentProfile:
system_prompt: str
model: str
effort: str
# Optional provider override. None inherits the global ``agent_provider``
# (EE=anthropic, CE=local). The service resolves model/effort/transport from
# settings.resolve_agent_provider() at option-build time; the model/effort
# fields above are the anthropic-path values and are overridden for local.
provider: str | None = None


STUDY_DESIGN = AgentProfile(
Expand Down
24 changes: 22 additions & 2 deletions ai/app/agents/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,16 @@ def _options(self, state: AgentSessionState) -> ClaudeAgentOptions:
writes = write_tools(state.profile_name)
server = create_sdk_mcp_server(name="parthenon", version="1.0.0", tools=tools)

# Resolve the effective provider (EE=anthropic cloud / CE=local model).
# The agent loop is model-agnostic; only the model/effort and the CLI's
# request target change. On the local provider with actions disabled, the
# approval-gated WRITE tools are withdrawn entirely so CE runs reads/chat
# only — operators opt into actions once their local model proves it can
# drive the tool-use + approval loop reliably.
resolved = settings.resolve_agent_provider(profile.provider)
if resolved.provider == "local" and not resolved.actions_enabled:
writes = set()

# Write tools are intentionally EXCLUDED from allowed_tools so that the
# CLI routes them through the can_use_tool callback (which gates on human
# approval). Read tools go into allowed_tools for auto-approval.
Expand All @@ -202,8 +212,8 @@ def _options(self, state: AgentSessionState) -> ClaudeAgentOptions:

kwargs: dict = dict(
system_prompt=profile.system_prompt,
model=profile.model,
effort=cast(EffortLevel, profile.effort),
model=resolved.model,
effort=cast(EffortLevel, resolved.effort),
mcp_servers={"parthenon": server},
tools=[],
allowed_tools=allowed,
Expand All @@ -214,6 +224,16 @@ def _options(self, state: AgentSessionState) -> ClaudeAgentOptions:
resume=state.anthropic_session_id,
)

# Redirect the CLI subprocess to the local Anthropic-compatible proxy.
# ClaudeAgentOptions.env is merged into the CLI's process environment, so
# this overrides ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN without touching
# python-ai's own env or the tool/streaming logic.
if resolved.provider == "local" and resolved.base_url:
kwargs["env"] = {
"ANTHROPIC_BASE_URL": resolved.base_url,
"ANTHROPIC_AUTH_TOKEN": resolved.auth_token or "local",
}

if has_writes:
kwargs["permission_mode"] = "default"
kwargs["can_use_tool"] = self._make_can_use_tool(state)
Expand Down
62 changes: 62 additions & 0 deletions ai/app/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
from typing import NamedTuple

from pydantic_settings import BaseSettings, SettingsConfigDict


class ResolvedAgentProvider(NamedTuple):
"""The effective model/transport for one agent turn after resolving the
global ``agent_provider`` against an optional per-profile override."""

provider: str # "anthropic" | "local"
model: str
effort: str
base_url: str | None # None for anthropic (CLI uses its built-in endpoint)
auth_token: str | None # None for anthropic (CLI uses ANTHROPIC_API_KEY)
actions_enabled: bool # whether approval-gated WRITE tools are available


class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")

Expand Down Expand Up @@ -93,6 +107,26 @@ class Settings(BaseSettings):
agent_max_concurrent_turns: int = 4
agent_approval_timeout_seconds: int = 600

# Agent provider switch (EE = Anthropic cloud; CE = local model via proxy).
# The agent loop (MCP tools, approval gating, streaming) is model-agnostic;
# only the CLI's request target and the auto-enabled tool set change.
# Defaults preserve EE behavior — overriding nothing keeps Anthropic/Opus.
agent_provider: str = "anthropic" # "anthropic" | "local"
# Anthropic-compatible proxy (claude-code-router / LiteLLM) that translates
# the Messages API to a local Ollama backend. Internal network only.
agent_local_base_url: str = "http://claude-router:8787"
# Tool-calling-capable local model. NOT MedGemma (a RAG model with weak
# function-calling); use Qwen2.5-Coder-32B / Llama-3.3-70B / Hermes-3.
agent_local_model: str = "qwen2.5-coder:32b"
# Dummy bearer the proxy accepts in place of a real Anthropic key.
agent_local_auth_token: str = "local"
# Gate the approval-gated WRITE tools on the local provider. Off by default:
# CE ships omnipresent Abby (reads/chat) on day one; operators opt in to
# actions per-deployment once their local model proves reliable tool-calling.
agent_local_actions_enabled: bool = False
# Local models ignore or break on high thinking budgets; cap effort.
agent_local_effort: str = "medium"

# Reverb (Pusher-protocol) — python-ai publishes agent events
reverb_app_id: str = ""
reverb_app_key: str = ""
Expand Down Expand Up @@ -136,5 +170,33 @@ def abby_llm_model(self) -> str:
def phenotype_llm_base_url(self) -> str:
return self.phenotype_interpreter_base_url or self.ollama_base_url

def resolve_agent_provider(self, profile_provider: str | None = None) -> ResolvedAgentProvider:
"""Resolve the effective provider for an agent turn.

``profile_provider`` lets a profile force a provider; ``None`` inherits
the global ``agent_provider``. For the local provider, returns the local
model/effort and the proxy transport, and whether write tools are enabled.
Anthropic returns the cloud model with no transport override (the CLI uses
its built-in endpoint + ANTHROPIC_API_KEY).
"""
provider = (profile_provider or self.agent_provider or "anthropic").lower()
if provider == "local":
return ResolvedAgentProvider(
provider="local",
model=self.agent_local_model,
effort=self.agent_local_effort,
base_url=self.agent_local_base_url,
auth_token=self.agent_local_auth_token,
actions_enabled=self.agent_local_actions_enabled,
)
return ResolvedAgentProvider(
provider="anthropic",
model=self.agent_model,
effort=self.agent_effort,
base_url=None,
auth_token=None,
actions_enabled=True,
)


settings = Settings()
13 changes: 12 additions & 1 deletion ai/app/routers/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
from pydantic import BaseModel, Field

from app.agents import registry
from app.agents.profiles import get_profile
from app.agents.service import AgentSessionState, ParthenonAgentService
from app.agents.tool_base import AgentToolContext
from app.config import settings

router = APIRouter()
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -55,7 +57,16 @@ async def create_session(body: CreateSessionRequest) -> dict:
tool_context=ctx,
)
registry.put(state)
return {"agent_session_id": body.agent_session_id, "channel": body.channel}
# Surface the effective provider + whether approval-gated WRITE actions are
# available, so the UI can hide action affordances on a reads-only CE
# deployment (local provider with actions disabled).
resolved = settings.resolve_agent_provider(get_profile(body.profile).provider)
return {
"agent_session_id": body.agent_session_id,
"channel": body.channel,
"provider": resolved.provider,
"actions_enabled": resolved.actions_enabled,
}


async def _run(agent_session_id: int, text: str, idempotency_key: str) -> None:
Expand Down
24 changes: 24 additions & 0 deletions ai/tests/test_agent_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,30 @@ async def fake_run_turn(self, state, text):
assert calls.get("ingest_path") == "/api/v1/studies/t2dm/design-sessions/7/agent/sessions/11/ingest"


def test_create_session_reports_anthropic_provider_by_default():
"""The create response surfaces provider/actions so the UI can gate action
affordances. Default (EE) is anthropic with actions enabled."""
create = client.post("/agent/sessions", json={**_CREATE_BODY, "agent_session_id": 311})
assert create.status_code == 200
body = create.json()
assert body["provider"] == "anthropic"
assert body["actions_enabled"] is True


def test_create_session_reports_local_reads_only(monkeypatch):
"""A reads-only CE deployment (local provider, actions disabled) reports
actions_enabled=false so the dock hides action affordances."""
import app.config as cfg
monkeypatch.setattr(cfg.settings, "agent_provider", "local")
monkeypatch.setattr(cfg.settings, "agent_local_actions_enabled", False)

create = client.post("/agent/sessions", json={**_CREATE_BODY, "agent_session_id": 312})
assert create.status_code == 200
body = create.json()
assert body["provider"] == "local"
assert body["actions_enabled"] is False


def test_create_session_stores_context_on_tool_context(monkeypatch):
from app.agents import registry as reg

Expand Down
59 changes: 59 additions & 0 deletions ai/tests/test_agent_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,3 +458,62 @@ async def test_resolve_approval_returns_false_for_unknown_tool_use_id():
service = ParthenonAgentService(publisher=MagicMock())
result = service.resolve_approval("nonexistent-tool-use-id", True)
assert result is False


# --- Provider switch (EE anthropic / CE local) ---------------------------------

async def test_options_anthropic_default_uses_cloud_model():
"""Default provider is anthropic: cloud model/effort, no env override,
write tools still approval-gated. Guards against CE work regressing EE."""
from app.config import settings as cfg_settings
service = ParthenonAgentService(publisher=MagicMock())
opts = service._options(_publish_state()) # publish has write tools

assert opts.model == cfg_settings.agent_model
assert opts.effort == cfg_settings.agent_effort
assert not (opts.env or {}).get("ANTHROPIC_BASE_URL")
assert opts.permission_mode == "default"
assert opts.can_use_tool is not None


async def test_options_local_injects_env_and_model(monkeypatch):
"""local provider redirects the CLI to the proxy and swaps in the local
model/effort; with actions enabled the write-tool gating is preserved."""
import app.config as cfg
monkeypatch.setattr(cfg.settings, "agent_provider", "local")
monkeypatch.setattr(cfg.settings, "agent_local_actions_enabled", True)

service = ParthenonAgentService(publisher=MagicMock())
opts = service._options(_publish_state())

assert opts.model == cfg.settings.agent_local_model
assert opts.effort == cfg.settings.agent_local_effort
assert opts.env["ANTHROPIC_BASE_URL"] == cfg.settings.agent_local_base_url
assert opts.env["ANTHROPIC_AUTH_TOKEN"] == cfg.settings.agent_local_auth_token
# actions enabled → write tools remain gated through can_use_tool
assert opts.permission_mode == "default"
assert opts.can_use_tool is not None


async def test_options_local_actions_disabled_withdraws_writes(monkeypatch):
"""local provider with actions disabled (CE default) withdraws all write
tools: they move into allowed_tools (auto-approved reads only), no approval
callback fires, and the env redirect is still applied."""
from app.agents.tool_packs import write_tools as wt
import app.config as cfg
monkeypatch.setattr(cfg.settings, "agent_provider", "local")
monkeypatch.setattr(cfg.settings, "agent_local_actions_enabled", False)

service = ParthenonAgentService(publisher=MagicMock())
opts = service._options(_publish_state())

allowed = set(opts.allowed_tools or [])
# every former write tool is now auto-approved (no human-in-the-loop on CE)
for write_name in wt("publish"):
assert f"mcp__parthenon__{write_name}" in allowed, (
f"write tool '{write_name}' should be withdrawn into allowed_tools when "
"local actions are disabled"
)
assert opts.permission_mode == "dontAsk"
assert opts.can_use_tool is None
assert opts.env["ANTHROPIC_BASE_URL"] == cfg.settings.agent_local_base_url
5 changes: 5 additions & 0 deletions backend/app/Http/Controllers/Api/V1/AbbyAgentController.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ public function start(Request $request, Study $study): JsonResponse
'data' => [
'agent_session_id' => $agentSession->id,
'channel_name' => $channel,
// Effective provider + whether approval-gated WRITE actions are
// available, so the dock hides action affordances on a reads-only
// CE deployment. Defaults preserve EE behavior if absent.
'provider' => $resp->json('provider', 'anthropic'),
'actions_enabled' => $resp->json('actions_enabled', true),
],
], 201);
}
Expand Down
21 changes: 20 additions & 1 deletion backend/tests/Feature/Studies/AbbyAgentControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
$resp = $this->actingAs($user)->postJson("/api/v1/studies/{$study->slug}/agent/sessions");

$resp->assertStatus(201)
->assertJsonPath('data.channel_name', "private-abby.study.{$study->id}");
->assertJsonPath('data.channel_name', "private-abby.study.{$study->id}")
// Defaults preserve EE behavior when python-ai omits the fields.
->assertJsonPath('data.provider', 'anthropic')
->assertJsonPath('data.actions_enabled', true);

$this->assertDatabaseHas('agent_sessions', [
'profile' => 'abby',
Expand All @@ -31,6 +34,22 @@
&& $req['context']['study_slug'] === $study->slug);
});

it('passes through a reads-only CE deployment provider + actions flag', function () {
Http::fake(['*/agent/sessions' => Http::response([
'provider' => 'local',
'actions_enabled' => false,
], 200)]);

$user = User::factory()->create();
$study = Study::create(['title' => 'CE study', 'created_by' => $user->id, 'status' => 'draft']);

$resp = $this->actingAs($user)->postJson("/api/v1/studies/{$study->slug}/agent/sessions");

$resp->assertStatus(201)
->assertJsonPath('data.provider', 'local')
->assertJsonPath('data.actions_enabled', false);
});

it('rejects a non-collaborator with 404 and never calls the agent service', function () {
Http::fake();

Expand Down
39 changes: 39 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,15 @@ services:
- AGENT_MODEL=${AGENT_MODEL:-claude-opus-4-7}
- AGENT_EFFORT=${AGENT_EFFORT:-xhigh}
- AGENT_MAX_BUDGET_USD=${AGENT_MAX_BUDGET_USD:-5}
# Agent provider switch (EE=anthropic cloud / CE=local model via proxy).
# Defaults keep EE behavior; CE sets AGENT_PROVIDER=local and runs the
# claude-router sidecar (docker compose --profile ce up -d claude-router).
- AGENT_PROVIDER=${AGENT_PROVIDER:-anthropic}
- AGENT_LOCAL_BASE_URL=${AGENT_LOCAL_BASE_URL:-http://claude-router:8787}
- AGENT_LOCAL_MODEL=${AGENT_LOCAL_MODEL:-qwen2.5-coder:32b}
- AGENT_LOCAL_AUTH_TOKEN=${AGENT_LOCAL_AUTH_TOKEN:-local}
- AGENT_LOCAL_ACTIONS_ENABLED=${AGENT_LOCAL_ACTIONS_ENABLED:-false}
- AGENT_LOCAL_EFFORT=${AGENT_LOCAL_EFFORT:-medium}
- KNOWLEDGE_CDM_SCHEMA=omop
- KNOWLEDGE_VOCAB_SCHEMA=vocab
extra_hosts:
Expand Down Expand Up @@ -267,6 +276,36 @@ services:
- parthenon
restart: unless-stopped

# CE-only: Anthropic-compatible proxy that lets the Claude Agent SDK / CLI
# target a LOCAL Ollama model instead of the Anthropic cloud API. Inert in the
# default (EE) stack — only starts with: docker compose --profile ce up -d.
# python-ai redirects the CLI here via ClaudeAgentOptions.env when
# AGENT_PROVIDER=local. Requires the operator's Ollama to have AGENT_LOCAL_MODEL
# pulled (e.g. `ollama pull qwen2.5-coder:32b`). NOT a clinical-grade tool
# caller by default — verify on your hardware before enabling agent actions.
claude-router:
container_name: parthenon-claude-router
profiles: ["ce"]
build:
context: .
dockerfile: docker/claude-router/Dockerfile
environment:
- ROUTER_PORT=8787
- ROUTER_API_KEY=${AGENT_LOCAL_AUTH_TOKEN:-local}
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
- LOCAL_MODEL=${AGENT_LOCAL_MODEL:-qwen2.5-coder:32b}
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD", "sh", "-c", "wget -qO- http://127.0.0.1:8787/ >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
networks:
- parthenon
restart: unless-stopped

chromadb:
container_name: parthenon-chromadb
image: chromadb/chroma:latest
Expand Down
Loading
Loading