diff --git a/.env.example b/.env.example index 5b36b8274..6a3a73ae2 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/ai/app/agents/profiles.py b/ai/app/agents/profiles.py index 24c4206df..ea341c6e8 100644 --- a/ai/app/agents/profiles.py +++ b/ai/app/agents/profiles.py @@ -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( diff --git a/ai/app/agents/service.py b/ai/app/agents/service.py index c06736da4..65e3e1336 100644 --- a/ai/app/agents/service.py +++ b/ai/app/agents/service.py @@ -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. @@ -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, @@ -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) diff --git a/ai/app/config.py b/ai/app/config.py index 662ed590d..5e8647595 100644 --- a/ai/app/config.py +++ b/ai/app/config.py @@ -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") @@ -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 = "" @@ -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() diff --git a/ai/app/routers/agent.py b/ai/app/routers/agent.py index 8ea9a7354..acf8d18a7 100644 --- a/ai/app/routers/agent.py +++ b/ai/app/routers/agent.py @@ -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__) @@ -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: diff --git a/ai/tests/test_agent_router.py b/ai/tests/test_agent_router.py index 66fbe56e8..c0c062b56 100644 --- a/ai/tests/test_agent_router.py +++ b/ai/tests/test_agent_router.py @@ -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 diff --git a/ai/tests/test_agent_service.py b/ai/tests/test_agent_service.py index c5fce6ff0..08ec4ec27 100644 --- a/ai/tests/test_agent_service.py +++ b/ai/tests/test_agent_service.py @@ -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 diff --git a/backend/app/Http/Controllers/Api/V1/AbbyAgentController.php b/backend/app/Http/Controllers/Api/V1/AbbyAgentController.php index 025e554f7..1234ceeec 100644 --- a/backend/app/Http/Controllers/Api/V1/AbbyAgentController.php +++ b/backend/app/Http/Controllers/Api/V1/AbbyAgentController.php @@ -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); } diff --git a/backend/tests/Feature/Studies/AbbyAgentControllerTest.php b/backend/tests/Feature/Studies/AbbyAgentControllerTest.php index 30fc9bf22..91095afbb 100644 --- a/backend/tests/Feature/Studies/AbbyAgentControllerTest.php +++ b/backend/tests/Feature/Studies/AbbyAgentControllerTest.php @@ -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', @@ -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(); diff --git a/docker-compose.yml b/docker-compose.yml index 1e030c0ab..f2136f815 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: @@ -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 diff --git a/docker/claude-router/Dockerfile b/docker/claude-router/Dockerfile new file mode 100644 index 000000000..c07b10b88 --- /dev/null +++ b/docker/claude-router/Dockerfile @@ -0,0 +1,27 @@ +# CE-only Anthropic→Ollama proxy for the Parthenon agent copilots. +# +# claude-code-router (musistudio) exposes an Anthropic Messages-API endpoint and +# forwards to an OpenAI-compatible backend (Ollama). python-ai points the Claude +# CLI here via ANTHROPIC_BASE_URL when AGENT_PROVIDER=local. The entrypoint +# renders the router config from env at start so the model/backend are +# deployment-configurable without rebaking the image. +# +# HIGHSEC 4.1: runs as the non-root `routeruser`. +FROM node:22-alpine + +# Pin can be tightened by the operator; @latest tracks upstream CCR. +RUN npm install -g @musistudio/claude-code-router + +RUN addgroup -S routergroup \ + && adduser -S -G routergroup -h /home/routeruser routeruser + +COPY docker/claude-router/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh \ + && mkdir -p /home/routeruser/.claude-code-router \ + && chown -R routeruser:routergroup /home/routeruser + +USER routeruser +ENV HOME=/home/routeruser + +EXPOSE 8787 +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/docker/claude-router/entrypoint.sh b/docker/claude-router/entrypoint.sh new file mode 100755 index 000000000..95ab8aa16 --- /dev/null +++ b/docker/claude-router/entrypoint.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# Render the claude-code-router config from env, then run the router in the +# foreground. The Claude CLI (driven by python-ai's agent service) connects with +# ANTHROPIC_AUTH_TOKEN == ROUTER_API_KEY and all routes map to the local Ollama +# model. Ollama must already have $LOCAL_MODEL pulled. +set -e + +ROUTER_PORT="${ROUTER_PORT:-8787}" +ROUTER_API_KEY="${ROUTER_API_KEY:-local}" +OLLAMA_BASE_URL="${OLLAMA_BASE_URL:-http://host.docker.internal:11434}" +LOCAL_MODEL="${LOCAL_MODEL:-qwen2.5-coder:32b}" + +CONFIG_DIR="${HOME}/.claude-code-router" +mkdir -p "${CONFIG_DIR}" + +cat > "${CONFIG_DIR}/config.json" < (provider, model, base_url, +auth_token, effort, actions_enabled)` so resolution lives in one place. + +### Phase 2 — Profile provider resolution (`ai/app/agents/profiles.py`) +- Add optional `provider: str | None = None` to `AgentProfile` (None = inherit global). +- Profiles keep referencing `settings.agent_model`/`effort` for the anthropic path; local + values come from Phase-1 settings at option-build time (no per-profile duplication). + +### Phase 3 — Env injection (`ai/app/agents/service.py::_options`) +Only `service.py` change. After building `kwargs`, when provider resolves to `local`: +override `model`/`effort`, set `kwargs["env"]` with `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN`, +set `fallback_model`, and when `agent_local_actions_enabled` is false, empty `writes` so +`has_writes` is false → `permission_mode="dontAsk"`, reads auto-approved, no action cards. +Everything else (MCP server, `tools=[]`, `strict_mcp_config`, streaming, persistence) untouched. + +### Phase 4 — Proxy sidecar (`docker-compose.yml`) +Anthropic→Ollama translation service. **Recommendation: `claude-code-router`** (purpose-built +for pointing Claude Code CLI at non-Anthropic backends; handles Messages-API request-shape +quirks). Alternative: LiteLLM `/v1/messages` passthrough. +- Service `claude-router`, internal network only (no host port), `env_file: backend/.env`. +- **HIGHSEC 4.1:** custom `docker/claude-router/Dockerfile` with non-root `USER` directive. +- Routes to `host.docker.internal:11434` (same Ollama as the RAG path). +- python-ai `depends_on` for the local provider. + +### Phase 5 — Capability exposed to UI (Laravel + frontend) +- python-ai session-create response (`/agent/sessions`) adds `actions_enabled: bool` + + `provider: str` from settings. +- Laravel passes through; `abbyDockStore` stores it; `AbbyCopilotPanel` hides the + pending-approval badge / action prompts when `actions_enabled === false`. Reads/chat unchanged. + +### Phase 6 — Tests +- **Python** (`test_agent_service.py`): `_options()` injects `env`/local model when + `agent_provider=local`; `writes` empties (+ `permission_mode=dontAsk`) when actions disabled; + anthropic path unchanged when provider=anthropic. Client mocked — no live model needed. +- **PHP**: session-create response carries `actions_enabled`/`provider`. +- **Frontend** (vitest): panel hides approval UI when `actions_enabled=false`. +- No new live-model dependency in CI (all mocked). + +### Phase 7 — Docs + deploy +- Devlog under `docs/lineage/modules/abby-ai/` with the EE/CE matrix. +- `.env.example`: add the six settings with EE-preserving defaults. +- `./deploy.sh`; `docker compose up -d python-ai claude-router` (env_file loads at creation). + +--- + +## EE / CE behavior matrix + +| | EE (`anthropic`) | CE (`local`, actions off) | CE (`local`, actions on) | +|---|---|---|---| +| Model | Opus 4.7/4.8 | Qwen2.5-Coder-32B (local) | same | +| Omnipresent dock | ✅ | ✅ | ✅ | +| Inline "Ask Abby" + reads | ✅ | ✅ | ✅ | +| Gated write actions | ✅ | ❌ (hidden) | ✅ (model-dependent) | +| External API cost | yes | $0 | $0 | + +## Risks / open decisions + +1. **Local tool-calling reliability** — the real risk. Mitigated by CE actions-off default; + operators opt in per-model. Recommend Qwen2.5-Coder-32B / Llama-3.3-70B / Hermes-3 — + explicitly **not** MedGemma (RAG model, weak function-calling). +2. **`effort`/thinking** — local models ignore/break on `xhigh`; Phase 1 forces `medium`. +3. **Proxy choice** — claude-code-router (CLI-faithful) vs LiteLLM (standard infra). +4. **Edition packaging** — pure env flag, independent of any EE/CE build marker. Installers + set `AGENT_PROVIDER`'s default per edition. + +## Sequencing + +Phases 1–3 + 6 are the core and are **verifiable today** without GPU or credit (tests mock the +client). Phases 4–5 are integration; Phase 7 the wrap. diff --git a/frontend/src/features/studies/api/abbyAgentApi.ts b/frontend/src/features/studies/api/abbyAgentApi.ts index 2f58a8356..e7ef22bf9 100644 --- a/frontend/src/features/studies/api/abbyAgentApi.ts +++ b/frontend/src/features/studies/api/abbyAgentApi.ts @@ -10,6 +10,12 @@ const base = (slug: string): string => `/studies/${slug}/agent/sessions`; export const startAbbySessionResponse = z.object({ agent_session_id: z.number(), channel_name: z.string(), + // Provider + whether approval-gated WRITE actions are available. Defaults + // preserve EE behavior (cloud Anthropic, actions on) when the field is absent; + // a reads-only CE deployment returns actions_enabled=false so the dock hides + // action affordances. + provider: z.string().optional().default("anthropic"), + actions_enabled: z.boolean().optional().default(true), }); export type StartAbbySessionResponse = z.infer; diff --git a/frontend/src/features/studies/components/AbbyCopilotPanel.tsx b/frontend/src/features/studies/components/AbbyCopilotPanel.tsx index 39cba966d..024a27342 100644 --- a/frontend/src/features/studies/components/AbbyCopilotPanel.tsx +++ b/frontend/src/features/studies/components/AbbyCopilotPanel.tsx @@ -22,7 +22,7 @@ interface AbbyCopilotPanelProps { */ export function AbbyCopilotPanel({ slug }: AbbyCopilotPanelProps) { const { start, starting, send, approve } = useAbbyAgent({ slug }); - const { transcript, isStreaming, agentSessionId, errorMessage, pendingApprovals, lastCostUsd } = + const { transcript, isStreaming, agentSessionId, errorMessage, pendingApprovals, lastCostUsd, actionsEnabled } = useAbbyAgentStore(); const isOpen = useAbbyDockStore((s) => s.isOpen); const queuedPrompt = useAbbyDockStore((s) => s.queuedPrompt); @@ -108,12 +108,20 @@ export function AbbyCopilotPanel({ slug }: AbbyCopilotPanelProps) {
{agentSessionId == null ? (
-

- Abby reads this study's design, gates, results, and manuscript, and can — on your - one-click approval — re-evaluate gates, refresh results, build a study package, and - open the manuscript in the Publisher. It never decides scientific validity: gate - approvals and overrides stay yours in the Gates timeline. -

+ {actionsEnabled ? ( +

+ Abby reads this study's design, gates, results, and manuscript, and can — on your + one-click approval — re-evaluate gates, refresh results, build a study package, and + open the manuscript in the Publisher. It never decides scientific validity: gate + approvals and overrides stay yours in the Gates timeline. +

+ ) : ( +

+ Abby reads this study's design, gates, results, and manuscript and answers your + questions. Action-taking is disabled on this deployment — gate evaluations, result + refreshes, and publishing run from the controls in each tab. +

+ )}